diff --git a/packages/compiler-cli/src/ngtsc/perf/src/api.ts b/packages/compiler-cli/src/ngtsc/perf/src/api.ts index 1f6ffa50a39aa4..7f76011fd493cf 100644 --- a/packages/compiler-cli/src/ngtsc/perf/src/api.ts +++ b/packages/compiler-cli/src/ngtsc/perf/src/api.ts @@ -153,6 +153,16 @@ export enum PerfPhase { * Tracks the number of `PerfPhase`s, and must appear at the end of the list. */ LAST, + + /** + * Time spent by the Angular Language Service calculating code fixes. + */ + LsCodeFixes, + + /** + * Time spent by the Angular Language Service to fix all detected same type errors. + */ + LsCodeFixesAll, } /** diff --git a/packages/language-service/src/BUILD.bazel b/packages/language-service/src/BUILD.bazel index e8c017f5be2bc5..8741cd44574c9e 100644 --- a/packages/language-service/src/BUILD.bazel +++ b/packages/language-service/src/BUILD.bazel @@ -4,7 +4,10 @@ package(default_visibility = ["//packages/language-service:__subpackages__"]) ts_library( name = "src", - srcs = glob(["*.ts"]), + srcs = glob([ + "*.ts", + "**/*.ts", + ]), deps = [ "//packages/compiler", "//packages/compiler-cli", diff --git a/packages/language-service/src/codefixes/all_codefixes_metas.ts b/packages/language-service/src/codefixes/all_codefixes_metas.ts new file mode 100644 index 00000000000000..b2a5bb0d80ec60 --- /dev/null +++ b/packages/language-service/src/codefixes/all_codefixes_metas.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 + */ + +import {missingMemberMeta} from './fix_missing_member'; +import {CodeActionMeta} from './utils'; + +export const ALL_CODE_FIXES_METAS: CodeActionMeta[] = [missingMemberMeta]; diff --git a/packages/language-service/src/codefixes/code_fixes.ts b/packages/language-service/src/codefixes/code_fixes.ts new file mode 100644 index 00000000000000..e03a40fc1f2fb5 --- /dev/null +++ b/packages/language-service/src/codefixes/code_fixes.ts @@ -0,0 +1,104 @@ +/** + * @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 {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core'; +import * as tss from 'typescript/lib/tsserverlibrary'; + +import {TemplateInfo} from '../utils'; + +import {CodeActionMeta, FixIdForCodeFixesAll, isFixAllAvailable} from './utils'; + +export class CodeFixes { + private errorCodeToFixes: Map = new Map(); + private fixIdToRegistration = new Map(); + + constructor( + private readonly tsLS: tss.LanguageService, readonly codeActionMetas: CodeActionMeta[]) { + for (const meta of codeActionMetas) { + for (const err of meta.errorCodes) { + let errMeta = this.errorCodeToFixes.get(err); + if (errMeta === undefined) { + this.errorCodeToFixes.set(err, errMeta = []); + } + errMeta.push(meta); + } + for (const fixId of meta.fixIds) { + if (this.fixIdToRegistration.has(fixId)) { + // https://github.com/microsoft/TypeScript/blob/28dc248e5c500c7be9a8c3a7341d303e026b023f/src/services/codeFixProvider.ts#L28 + // In ts services, only one meta can be registered for a fixId. + continue; + } + this.fixIdToRegistration.set(fixId, meta); + } + } + } + + /** + * When the user moves the cursor or hovers on a diagnostics, this function will be invoked by LS, + * and collect all the responses from the `codeActionMetas` which could handle the `errorCodes`. + */ + getCodeFixesAtPosition( + templateInfo: TemplateInfo, compiler: NgCompiler, start: number, end: number, + errorCodes: readonly number[], diagnostics: tss.Diagnostic[], + formatOptions: tss.FormatCodeSettings, + preferences: tss.UserPreferences): readonly tss.CodeFixAction[] { + const codeActions: tss.CodeFixAction[] = []; + for (const code of errorCodes) { + const metas = this.errorCodeToFixes.get(code); + if (metas === undefined) { + continue; + } + for (const meta of metas) { + const codeActionsForMeta = meta.getCodeActions({ + templateInfo, + compiler, + start, + end, + errorCode: code, + formatOptions, + preferences, + tsLs: this.tsLS, + }); + const fixAllAvailable = isFixAllAvailable(meta, diagnostics); + const removeFixIdForCodeActions = + codeActionsForMeta.map(({fixId, fixAllDescription, ...codeActionForMeta}) => { + return fixAllAvailable ? {...codeActionForMeta, fixId, fixAllDescription} : + codeActionForMeta; + }); + codeActions.push(...removeFixIdForCodeActions); + } + } + return codeActions; + } + + /** + * When the user wants to fix the all same type of diagnostics in the `scope`, this function will + * be called and fix all diagnostics which will be filtered by the `errorCodes` from the + * `CodeActionMeta` that the `fixId` belongs to. + */ + getAllCodeActions( + compiler: NgCompiler, diagnostics: tss.Diagnostic[], scope: tss.CombinedCodeFixScope, + fixId: string, formatOptions: tss.FormatCodeSettings, + preferences: tss.UserPreferences): tss.CombinedCodeActions { + const meta = this.fixIdToRegistration.get(fixId as FixIdForCodeFixesAll); + if (meta === undefined) { + return { + changes: [], + }; + } + return meta.getAllCodeActions({ + compiler, + fixId, + formatOptions, + preferences, + tsLs: this.tsLS, + scope, + diagnostics, + }); + } +} diff --git a/packages/language-service/src/codefixes/fix_missing_member.ts b/packages/language-service/src/codefixes/fix_missing_member.ts new file mode 100644 index 00000000000000..8882a06d356613 --- /dev/null +++ b/packages/language-service/src/codefixes/fix_missing_member.ts @@ -0,0 +1,99 @@ +/** + * @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 {findFirstMatchingNode} from '@angular/compiler-cli/src/ngtsc/typecheck/src/comments'; +import * as e from '@angular/compiler/src/expression_parser/ast'; // e for expression AST +import ts from 'typescript'; +import * as tss from 'typescript/lib/tsserverlibrary'; + +import {getTargetAtPosition, getTcbNodesOfTemplateAtPosition, TargetNodeKind} from '../template_target'; +import {getTemplateInfoAtPosition} from '../utils'; + +import {CodeActionMeta, convertFileTextChangeInTcb, FixIdForCodeFixesAll} from './utils'; + +const errorCodes: number[] = [ + 2551, // https://github.com/microsoft/TypeScript/blob/8e6e87fea6463e153822e88431720f846c3b8dfa/src/compiler/diagnosticMessages.json#L2493 + 2339, // https://github.com/microsoft/TypeScript/blob/8e6e87fea6463e153822e88431720f846c3b8dfa/src/compiler/diagnosticMessages.json#L1717 +]; + +/** + * This code action will fix the missing member of a type. For example, add the missing member to + * the type or try to get the spelling suggestion for the name from the type. + */ +export const missingMemberMeta: CodeActionMeta = { + errorCodes, + getCodeActions: function( + {templateInfo, start, compiler, formatOptions, preferences, errorCode, tsLs}) { + const tcbNodesInfo = getTcbNodesOfTemplateAtPosition(templateInfo, start, compiler); + if (tcbNodesInfo === null) { + return []; + } + + const codeActions: ts.CodeFixAction[] = []; + const tcb = tcbNodesInfo.componentTcbNode; + for (const tcbNode of tcbNodesInfo.nodes) { + const tsLsCodeActions = tsLs.getCodeFixesAtPosition( + tcb.getSourceFile().fileName, tcbNode.getStart(), tcbNode.getEnd(), [errorCode], + formatOptions, preferences); + codeActions.push(...tsLsCodeActions); + } + return codeActions.map(codeAction => { + return { + fixName: codeAction.fixName, + fixId: codeAction.fixId, + fixAllDescription: codeAction.fixAllDescription, + description: codeAction.description, + changes: convertFileTextChangeInTcb(codeAction.changes, compiler), + commands: codeAction.commands, + }; + }); + }, + fixIds: [FixIdForCodeFixesAll.FIX_SPELLING, FixIdForCodeFixesAll.FIX_MISSING_MEMBER], + getAllCodeActions: function( + {tsLs, scope, fixId, formatOptions, preferences, compiler, diagnostics}) { + const changes: tss.FileTextChanges[] = []; + const seen: Set = new Set(); + for (const diag of diagnostics) { + if (!errorCodes.includes(diag.code)) { + continue; + } + + const fileName = diag.file?.fileName; + if (fileName === undefined) { + continue; + } + if (diag.start === undefined) { + continue; + } + const componentClass = getTemplateInfoAtPosition(fileName, diag.start, compiler)?.component; + if (componentClass === undefined) { + continue; + } + if (seen.has(componentClass)) { + continue; + } + seen.add(componentClass); + + const tcb = compiler.getTemplateTypeChecker().getTypeCheckBlock(componentClass); + if (tcb === null) { + continue; + } + + const combinedCodeActions = tsLs.getCombinedCodeFix( + { + type: scope.type, + fileName: tcb.getSourceFile().fileName, + }, + fixId, formatOptions, preferences); + changes.push(...combinedCodeActions.changes); + } + return { + changes: convertFileTextChangeInTcb(changes, compiler), + }; + } +}; diff --git a/packages/language-service/src/codefixes/index.ts b/packages/language-service/src/codefixes/index.ts new file mode 100644 index 00000000000000..cbc7288d16e224 --- /dev/null +++ b/packages/language-service/src/codefixes/index.ts @@ -0,0 +1,10 @@ +/** + * @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 {ALL_CODE_FIXES_METAS} from './all_codefixes_metas'; +export {CodeFixes} from './code_fixes'; diff --git a/packages/language-service/src/codefixes/utils.ts b/packages/language-service/src/codefixes/utils.ts new file mode 100644 index 00000000000000..587e69c6fda20f --- /dev/null +++ b/packages/language-service/src/codefixes/utils.ts @@ -0,0 +1,135 @@ +/** + * @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'; +import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core'; +import * as tss from 'typescript/lib/tsserverlibrary'; + +import {TemplateInfo} from '../utils'; + +/** + * This context is the info includes the `errorCode` at the given span the user selected in the + * editor and the `NgCompiler` could help to fix it. + * + * When the editor tries to provide a code fix for a diagnostic in a span of a template file, this + * context will be provided to the `CodeActionMeta` which could handle the `errorCode`. + */ +export interface CodeActionContext { + templateInfo: TemplateInfo; + compiler: NgCompiler; + start: number; + end: number; + errorCode: number; + formatOptions: tss.FormatCodeSettings; + preferences: tss.UserPreferences; + tsLs: tss.LanguageService; +} + +/** + * This context is the info includes all diagnostics in the `scope` and the `NgCompiler` that could + * help to fix it. + * + * When the editor tries to fix the all same type of diagnostics selected by the user in the + * `scope`, this context will be provided to the `CodeActionMeta` which could handle the `fixId`. + */ +export interface CodeFixAllContext { + scope: tss.CombinedCodeFixScope; + compiler: NgCompiler; + // https://github.com/microsoft/TypeScript/blob/5c4caafc2a2d0fceb03fce80fb14d3ee4407d918/src/services/types.ts#L781-L785 + fixId: string; + formatOptions: tss.FormatCodeSettings; + preferences: tss.UserPreferences; + tsLs: tss.LanguageService; + diagnostics: tss.Diagnostic[]; +} + +export interface CodeActionMeta { + errorCodes: Array; + getCodeActions: (context: CodeActionContext) => readonly tss.CodeFixAction[]; + fixIds: FixIdForCodeFixesAll[]; + getAllCodeActions: (context: CodeFixAllContext) => tss.CombinedCodeActions; +} + +/** + * Convert the span of `textChange` in the TCB to the span of the template. + */ +export function convertFileTextChangeInTcb( + changes: readonly tss.FileTextChanges[], compiler: NgCompiler): tss.FileTextChanges[] { + const ttc = compiler.getTemplateTypeChecker(); + const fileTextChanges: tss.FileTextChanges[] = []; + for (const fileTextChange of changes) { + if (!ttc.isTrackedTypeCheckFile(absoluteFrom(fileTextChange.fileName))) { + fileTextChanges.push(fileTextChange); + continue; + } + const textChanges: tss.TextChange[] = []; + let fileName: string|undefined; + const seenTextChangeInTemplate = new Set(); + for (const textChange of fileTextChange.textChanges) { + const templateMap = ttc.getTemplateMappingAtTcbLocation({ + tcbPath: absoluteFrom(fileTextChange.fileName), + isShimFile: true, + positionInFile: textChange.span.start, + }); + if (templateMap === null) { + continue; + } + const mapping = templateMap.templateSourceMapping; + if (mapping.type === 'external') { + fileName = mapping.templateUrl; + } else if (mapping.type === 'direct') { + fileName = mapping.node.getSourceFile().fileName; + } else { + continue; + } + const start = templateMap.span.start.offset; + const length = templateMap.span.end.offset - templateMap.span.start.offset; + const changeSpanKey = `${start},${length}`; + if (seenTextChangeInTemplate.has(changeSpanKey)) { + continue; + } + seenTextChangeInTemplate.add(changeSpanKey); + textChanges.push({ + newText: textChange.newText, + span: { + start, + length, + }, + }); + } + if (fileName === undefined) { + continue; + } + fileTextChanges.push({ + fileName, + isNewFile: fileTextChange.isNewFile, + textChanges, + }); + } + return fileTextChanges; +} + +/** + * 'fix all' is only available when there are multiple diagnostics that the code action meta + * indicates it can fix. + */ +export function isFixAllAvailable(meta: CodeActionMeta, diagnostics: tss.Diagnostic[]) { + const errorCodes = meta.errorCodes; + let maybeFixableDiagnostics = 0; + for (const diag of diagnostics) { + if (errorCodes.includes(diag.code)) maybeFixableDiagnostics++; + if (maybeFixableDiagnostics > 1) return true; + } + + return false; +} + +export enum FixIdForCodeFixesAll { + FIX_SPELLING = 'fixSpelling', + FIX_MISSING_MEMBER = 'fixMissingMember', +} diff --git a/packages/language-service/src/language_service.ts b/packages/language-service/src/language_service.ts index 395d3fd37c252f..2bae90435b55d6 100644 --- a/packages/language-service/src/language_service.ts +++ b/packages/language-service/src/language_service.ts @@ -22,6 +22,7 @@ import * as ts from 'typescript/lib/tsserverlibrary'; import {GetComponentLocationsForTemplateResponse, GetTcbResponse, GetTemplateLocationForComponentResponse} from '../api'; import {LanguageServiceAdapter, LSParseConfigHost} from './adapters'; +import {ALL_CODE_FIXES_METAS, CodeFixes} from './codefixes'; import {CompilerFactory} from './compiler_factory'; import {CompletionBuilder, CompletionNodeContext} from './completions'; import {DefinitionBuilder} from './definitions'; @@ -29,7 +30,7 @@ import {QuickInfoBuilder} from './quick_info'; import {ReferencesBuilder, RenameBuilder} from './references_and_rename'; import {createLocationKey} from './references_and_rename_utils'; import {getSignatureHelp} from './signature_help'; -import {getTargetAtPosition, TargetContext, TargetNodeKind} from './template_target'; +import {getTargetAtPosition, getTcbNodesOfTemplateAtPosition, TargetContext, TargetNodeKind} from './template_target'; import {findTightestNode, getClassDeclFromDecoratorProp, getParentClassDeclaration, getPropertyAssignmentFromValue} from './ts_utils'; import {getTemplateInfoAtPosition, isTypeScriptFile} from './utils'; @@ -47,6 +48,7 @@ export class LanguageService { private readonly programDriver: ProgramDriver; private readonly adapter: LanguageServiceAdapter; private readonly parseConfigHost: LSParseConfigHost; + private readonly codeFixes: CodeFixes; constructor( private readonly project: ts.server.Project, @@ -60,6 +62,7 @@ export class LanguageService { this.adapter = new LanguageServiceAdapter(project); this.compilerFactory = new CompilerFactory(this.adapter, this.programDriver, this.options); this.watchConfigFile(project); + this.codeFixes = new CodeFixes(tsLS, ALL_CODE_FIXES_METAS); } getCompilerOptions(): CompilerOptions { @@ -289,6 +292,39 @@ export class LanguageService { }); } + getCodeFixesAtPosition( + fileName: string, start: number, end: number, errorCodes: readonly number[], + formatOptions: ts.FormatCodeSettings, + preferences: ts.UserPreferences): readonly ts.CodeFixAction[] { + return this.withCompilerAndPerfTracing( + PerfPhase.LsCodeFixes, (compiler) => { + const templateInfo = getTemplateInfoAtPosition(fileName, start, compiler); + if (templateInfo === undefined) { + return []; + } + const diags = this.getSemanticDiagnostics(fileName); + if (diags.length === 0) { + return []; + } + return this.codeFixes.getCodeFixesAtPosition( + templateInfo, compiler, start, end, errorCodes, diags, formatOptions, preferences); + }); + } + + getCombinedCodeFix( + scope: ts.CombinedCodeFixScope, fixId: string, formatOptions: ts.FormatCodeSettings, + preferences: ts.UserPreferences): ts.CombinedCodeActions { + return this.withCompilerAndPerfTracing( + PerfPhase.LsCodeFixesAll, (compiler) => { + const diags = this.getSemanticDiagnostics(scope.fileName); + if (diags.length === 0) { + return {changes: []}; + } + return this.codeFixes.getAllCodeActions( + compiler, diags, scope, fixId, formatOptions, preferences); + }); + } + getComponentLocationsForTemplate(fileName: string): GetComponentLocationsForTemplateResponse { return this.withCompilerAndPerfTracing( PerfPhase.LsComponentLocations, (compiler) => { @@ -351,36 +387,20 @@ export class LanguageService { if (templateInfo === undefined) { return undefined; } - const tcb = compiler.getTemplateTypeChecker().getTypeCheckBlock(templateInfo.component); - if (tcb === null) { + + const selectionNodesInfo = getTcbNodesOfTemplateAtPosition(templateInfo, position, compiler); + if (selectionNodesInfo === null) { return undefined; } - const sf = tcb.getSourceFile(); - - let selections: ts.TextSpan[] = []; - const target = getTargetAtPosition(templateInfo.template, position); - if (target !== null) { - let selectionSpans: Array; - if ('nodes' in target.context) { - selectionSpans = target.context.nodes.map(n => n.sourceSpan); - } else { - selectionSpans = [target.context.node.sourceSpan]; - } - const selectionNodes: ts.Node[] = - selectionSpans - .map(s => findFirstMatchingNode(tcb, { - withSpan: s, - filter: (node: ts.Node): node is ts.Node => true, - })) - .filter((n): n is ts.Node => n !== null); - - selections = selectionNodes.map(n => { - return { - start: n.getStart(sf), - length: n.getEnd() - n.getStart(sf), - }; - }); - } + + const sf = selectionNodesInfo.componentTcbNode.getSourceFile(); + + const selections = selectionNodesInfo.nodes.map(n => { + return { + start: n.getStart(sf), + length: n.getEnd() - n.getStart(sf), + }; + }); return { fileName: sf.fileName, diff --git a/packages/language-service/src/template_target.ts b/packages/language-service/src/template_target.ts index 824a659391e1a4..a51a4a6b28e78a 100644 --- a/packages/language-service/src/template_target.ts +++ b/packages/language-service/src/template_target.ts @@ -6,11 +6,14 @@ * found in the LICENSE file at https://angular.io/license */ -import {ParseSpan, TmplAstBoundEvent} from '@angular/compiler'; +import {ParseSourceSpan, ParseSpan, TmplAstBoundEvent} from '@angular/compiler'; +import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core'; +import {findFirstMatchingNode} from '@angular/compiler-cli/src/ngtsc/typecheck/src/comments'; import * as e from '@angular/compiler/src/expression_parser/ast'; // e for expression AST import * as t from '@angular/compiler/src/render3/r3_ast'; // t for template AST +import * as ts from 'typescript/lib/typescript'; -import {isBoundEventWithSyntheticHandler, isTemplateNodeWithKeyAndValue, isWithin, isWithinKeyValue} from './utils'; +import {isBoundEventWithSyntheticHandler, isTemplateNodeWithKeyAndValue, isWithin, isWithinKeyValue, TemplateInfo} from './utils'; /** * Contextual information for a target position within the template. @@ -260,6 +263,72 @@ export function getTargetAtPosition(template: t.Node[], position: number): Templ return {position, context: nodeInContext, template: context, parent}; } +function findFirstMatchingNodeForSourceSpan( + tcb: ts.Node, sourceSpan: ParseSourceSpan|e.AbsoluteSourceSpan) { + return findFirstMatchingNode( + tcb, + { + withSpan: sourceSpan, + filter: (node: ts.Node): node is ts.Node => true, + }, + ); +} + +/** + * A tcb nodes for the template at a given position, include the tcb node of the template. + */ +interface TcbNodesInfoForTemplate { + componentTcbNode: ts.Node; + nodes: ts.Node[]; +} + +/** + * Return the nodes in `TCB` of the node at the specified cursor `position`. + * + */ +export function getTcbNodesOfTemplateAtPosition( + templateInfo: TemplateInfo, position: number, compiler: NgCompiler): TcbNodesInfoForTemplate| + null { + const target = getTargetAtPosition(templateInfo.template, position); + if (target === null) { + return null; + } + + const tcb = compiler.getTemplateTypeChecker().getTypeCheckBlock(templateInfo.component); + if (tcb === null) { + return null; + } + + const selectionSpans: (ts.Node|null)[] = []; + if (target.context.kind === TargetNodeKind.RawExpression) { + const span = target.context.node; + if (span instanceof e.PropertyRead) { + const tsNode = findFirstMatchingNode(tcb, { + withSpan: span.nameSpan, + filter: (node): node is ts.PropertyAccessExpression => ts.isPropertyAccessExpression(node) + }); + selectionSpans.push(tsNode?.name ?? null); + } else { + const tsNode = findFirstMatchingNodeForSourceSpan(tcb, target.context.node.sourceSpan); + selectionSpans.push(tsNode); + } + } else if (target.context.kind === TargetNodeKind.TwoWayBindingContext) { + const spans = target.context.nodes.map(n => n.sourceSpan).map((node) => { + return findFirstMatchingNodeForSourceSpan(tcb, node); + }); + selectionSpans.push(...spans); + } else { + selectionSpans.push(findFirstMatchingNodeForSourceSpan(tcb, target.context.node.sourceSpan)); + } + + const selectionNodes: ts.Node[] = selectionSpans.filter((n): n is ts.Node => n !== null); + + return { + nodes: selectionNodes, + componentTcbNode: tcb, + }; +} + /** * Visitor which, given a position and a template, identifies the node within the template at that * position, as well as records the path of increasingly nested nodes that were traversed to reach diff --git a/packages/language-service/src/ts_plugin.ts b/packages/language-service/src/ts_plugin.ts index 4ad8736eb6fa69..650bd25d5184d0 100644 --- a/packages/language-service/src/ts_plugin.ts +++ b/packages/language-service/src/ts_plugin.ts @@ -164,6 +164,38 @@ export function create(info: ts.server.PluginCreateInfo): NgLanguageService { return ngLS.getTemplateLocationForComponent(fileName, position); } + function getCodeFixesAtPosition( + fileName: string, start: number, end: number, errorCodes: readonly number[], + formatOptions: ts.FormatCodeSettings, + preferences: ts.UserPreferences): readonly ts.CodeFixAction[] { + if (angularOnly) { + return ngLS.getCodeFixesAtPosition( + fileName, start, end, errorCodes, formatOptions, preferences); + } else { + const tsLsCodeFixes = + tsLS.getCodeFixesAtPosition(fileName, start, end, errorCodes, formatOptions, preferences); + // If TS could answer the query, then return that result. Otherwise, return from Angular LS. + return tsLsCodeFixes.length > 0 ? + tsLsCodeFixes : + ngLS.getCodeFixesAtPosition(fileName, start, end, errorCodes, formatOptions, preferences); + } + } + + function getCombinedCodeFix( + scope: ts.CombinedCodeFixScope, fixId: string, formatOptions: ts.FormatCodeSettings, + preferences: ts.UserPreferences): ts.CombinedCodeActions { + if (angularOnly) { + return ngLS.getCombinedCodeFix(scope, fixId, formatOptions, preferences); + } else { + const tsLsCombinedCodeFix = tsLS.getCombinedCodeFix(scope, fixId, formatOptions, preferences); + // If TS could answer the query, then return that result. Otherwise, return from Angular LS. + return tsLsCombinedCodeFix.changes.length > 0 ? + tsLsCombinedCodeFix : + ngLS.getCombinedCodeFix(scope, fixId, formatOptions, preferences); + } + } + + return { ...tsLS, getSemanticDiagnostics, @@ -181,6 +213,8 @@ export function create(info: ts.server.PluginCreateInfo): NgLanguageService { getComponentLocationsForTemplate, getSignatureHelpItems, getTemplateLocationForComponent, + getCodeFixesAtPosition, + getCombinedCodeFix, }; } diff --git a/packages/language-service/test/code_fixes_spec.ts b/packages/language-service/test/code_fixes_spec.ts new file mode 100644 index 00000000000000..9beb937ef238a3 --- /dev/null +++ b/packages/language-service/test/code_fixes_spec.ts @@ -0,0 +1,256 @@ +/** + * @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 {initMockFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing'; + +import {createModuleAndProjectWithDeclarations, LanguageServiceTestEnv} from '../testing'; + +describe('code fixes', () => { + let env: LanguageServiceTestEnv; + beforeEach(() => { + initMockFileSystem('Native'); + env = LanguageServiceTestEnv.setup(); + }); + + it('should fix error when property does not exist on type', () => { + const files = { + 'app.ts': ` + import {Component, NgModule} from '@angular/core'; + + @Component({ + templateUrl: './app.html' + }) + export class AppComponent { + title1 = ''; + } + `, + 'app.html': `{{title}}` + }; + + const project = createModuleAndProjectWithDeclarations(env, 'test', files); + const diags = project.getDiagnosticsForFile('app.html'); + const appFile = project.openFile('app.html'); + appFile.moveCursorToText('title¦'); + const codeActions = + project.getCodeFixesAtPosition('app.html', appFile.cursor, appFile.cursor, [diags[0].code]); + expectIncludeReplacementText({ + codeActions, + content: appFile.contents, + text: 'title', + newText: 'title1', + fileName: 'app.html' + }); + + const appTsFile = project.openFile('app.ts'); + appTsFile.moveCursorToText(`title1 = '';\n¦`); + expectIncludeAddText( + {codeActions, position: appTsFile.cursor, text: 'title: any;\n', fileName: 'app.ts'}); + }); + + it('should fix a missing method when property does not exist on type', () => { + const files = { + 'app.ts': ` + import {Component, NgModule} from '@angular/core'; + + @Component({ + templateUrl: './app.html' + }) + export class AppComponent { + } + `, + 'app.html': `{{title('Angular')}}` + }; + + const project = createModuleAndProjectWithDeclarations(env, 'test', files); + const diags = project.getDiagnosticsForFile('app.html'); + const appFile = project.openFile('app.html'); + appFile.moveCursorToText('title¦'); + const codeActions = + project.getCodeFixesAtPosition('app.html', appFile.cursor, appFile.cursor, [diags[0].code]); + + const appTsFile = project.openFile('app.ts'); + appTsFile.moveCursorToText(`class AppComponent {¦`); + expectIncludeAddText({ + codeActions, + position: appTsFile.cursor, + text: `\ntitle(arg0: string) {\nthrow new Error('Method not implemented.');\n}`, + fileName: 'app.ts' + }); + }); + + it('should not show fix all errors when there is only one diagnostic in the template but has two or more diagnostics in TCB', + () => { + const files = { + 'app.ts': ` + import {Component, NgModule} from '@angular/core'; + + @Component({ + templateUrl: './app.html' + }) + export class AppComponent { + title1 = ''; + } + `, + 'app.html': `
` + }; + + const project = createModuleAndProjectWithDeclarations(env, 'test', files); + const diags = project.getDiagnosticsForFile('app.html'); + const appFile = project.openFile('app.html'); + appFile.moveCursorToText('title¦'); + const codeActions = project.getCodeFixesAtPosition( + 'app.html', appFile.cursor, appFile.cursor, [diags[0].code]); + expectNotIncludeFixAllInfo(codeActions); + }); + + it('should fix all errors when property does not exist on type', () => { + const files = { + 'app.ts': ` + import {Component, NgModule} from '@angular/core'; + + @Component({ + template: '{{tite}}{{bannr}}', + }) + export class AppComponent { + title = ''; + banner = ''; + } + `, + }; + + const project = createModuleAndProjectWithDeclarations(env, 'test', files); + const appFile = project.openFile('app.ts'); + + const fixesAllSpelling = project.getCombinedCodeFix('app.ts', 'fixSpelling' as string); + expectIncludeReplacementTextForFileTextChange({ + fileTextChanges: fixesAllSpelling.changes, + content: appFile.contents, + text: 'tite', + newText: 'title', + fileName: 'app.ts' + }); + expectIncludeReplacementTextForFileTextChange({ + fileTextChanges: fixesAllSpelling.changes, + content: appFile.contents, + text: 'bannr', + newText: 'banner', + fileName: 'app.ts' + }); + + const fixAllMissingMember = project.getCombinedCodeFix('app.ts', 'fixMissingMember' as string); + appFile.moveCursorToText(`banner = '';\n¦`); + expectIncludeAddTextForFileTextChange({ + fileTextChanges: fixAllMissingMember.changes, + position: appFile.cursor, + text: 'tite: any;\n', + fileName: 'app.ts' + }); + expectIncludeAddTextForFileTextChange({ + fileTextChanges: fixAllMissingMember.changes, + position: appFile.cursor, + text: 'bannr: any;\n', + fileName: 'app.ts' + }); + }); +}); + +function expectNotIncludeFixAllInfo(codeActions: readonly ts.CodeFixAction[]) { + for (const codeAction of codeActions) { + expect(codeAction.fixId).toBeUndefined(); + expect(codeAction.fixAllDescription).toBeUndefined(); + } +} + +function expectIncludeReplacementText({codeActions, content, text, newText, fileName}: { + codeActions: readonly ts.CodeAction[]; content: string; text: string; newText: string; + fileName: string; +}) { + let includeReplacementText = false; + for (const codeAction of codeActions) { + includeReplacementText = includeReplacementTextInChanges( + {fileTextChanges: codeAction.changes, content, text, newText, fileName}); + if (includeReplacementText) { + return; + } + } + expect(includeReplacementText).toBeTruthy(); +} + +function expectIncludeAddText({codeActions, position, text, fileName}: { + codeActions: readonly ts.CodeAction[]; position: number; text: string; fileName: string; +}) { + let includeAddText = false; + for (const codeAction of codeActions) { + includeAddText = + includeAddTextInChanges({fileTextChanges: codeAction.changes, position, text, fileName}); + if (includeAddText) { + return; + } + } + expect(includeAddText).toBeTruthy(); +} + +function expectIncludeReplacementTextForFileTextChange( + {fileTextChanges, content, text, newText, fileName}: { + fileTextChanges: readonly ts.FileTextChanges[]; content: string; text: string; + newText: string; + fileName: string; + }) { + expect(includeReplacementTextInChanges({fileTextChanges, content, text, newText, fileName})) + .toBeTruthy(); +} + +function expectIncludeAddTextForFileTextChange({fileTextChanges, position, text, fileName}: { + fileTextChanges: readonly ts.FileTextChanges[]; position: number; text: string; fileName: string; +}) { + expect(includeAddTextInChanges({fileTextChanges, position, text, fileName})).toBeTruthy(); +} + +function includeReplacementTextInChanges({fileTextChanges, content, text, newText, fileName}: { + fileTextChanges: readonly ts.FileTextChanges[]; content: string; text: string; newText: string; + fileName: string; +}) { + for (const change of fileTextChanges) { + if (!change.fileName.endsWith(fileName)) { + continue; + } + for (const textChange of change.textChanges) { + if (textChange.span.length === 0) { + continue; + } + const includeReplaceText = + content.slice(textChange.span.start, textChange.span.start + textChange.span.length) === + text && + newText === textChange.newText; + if (includeReplaceText) { + return true; + } + } + } + return false; +} + +function includeAddTextInChanges({fileTextChanges, position, text, fileName}: { + fileTextChanges: readonly ts.FileTextChanges[]; position: number; text: string; fileName: string; +}) { + for (const change of fileTextChanges) { + if (!change.fileName.endsWith(fileName)) { + continue; + } + for (const textChange of change.textChanges) { + if (textChange.span.length > 0) { + continue; + } + const includeAddText = position === textChange.span.start && text === textChange.newText; + if (includeAddText) { + return true; + } + } + } + return false; +} diff --git a/packages/language-service/testing/src/project.ts b/packages/language-service/testing/src/project.ts index 59fea5ed2e1ef3..f12a13abdea502 100644 --- a/packages/language-service/testing/src/project.ts +++ b/packages/language-service/testing/src/project.ts @@ -144,6 +144,26 @@ export class Project { return diagnostics; } + getCodeFixesAtPosition( + projectFileName: string, + start: number, + end: number, + errorCodes: readonly number[], + ): readonly ts.CodeFixAction[] { + const fileName = absoluteFrom(`/${this.name}/${projectFileName}`); + return this.ngLS.getCodeFixesAtPosition(fileName, start, end, errorCodes, {}, {}); + } + + getCombinedCodeFix(projectFileName: string, fixId: string): ts.CombinedCodeActions { + const fileName = absoluteFrom(`/${this.name}/${projectFileName}`); + return this.ngLS.getCombinedCodeFix( + { + type: 'file', + fileName, + }, + fixId, {}, {}); + } + expectNoSourceDiagnostics(): void { const program = this.tsLS.getProgram(); if (program === undefined) {