Skip to content

Commit

Permalink
feat(language-service): support fix the component missing member (#46764
Browse files Browse the repository at this point in the history
)

The diagnostic of the component missing member comes from the ts service,
so the all code fixes for it are delegated to the ts service.

The code fixes are placed in the LS package because only LS can benefit from
it now, and The LS knows how to provide code fixes by the diagnostic and NgCompiler.

The class `CodeFixes` is useful to extend the code fixes if LS needs to
provide more code fixes for the template in the future. The ts service uses
the same way to provide code fixes.

https://github.com/microsoft/TypeScript/blob/162224763681465b417274383317ca9a0a573835/src/services/codeFixProvider.ts#L22

Fixes angular/vscode-ng-language-service#1610

PR Close #46764
  • Loading branch information
ivanwonder authored and AndrewKushnir committed Aug 2, 2022
1 parent d8cf78b commit 598b72b
Show file tree
Hide file tree
Showing 12 changed files with 801 additions and 32 deletions.
10 changes: 10 additions & 0 deletions packages/compiler-cli/src/ngtsc/perf/src/api.ts
Expand Up @@ -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,
}

/**
Expand Down
5 changes: 4 additions & 1 deletion packages/language-service/src/BUILD.bazel
Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions 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];
104 changes: 104 additions & 0 deletions 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<number, CodeActionMeta[]> = new Map();
private fixIdToRegistration = new Map<FixIdForCodeFixesAll, CodeActionMeta>();

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,
});
}
}
99 changes: 99 additions & 0 deletions 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<tss.ClassDeclaration> = 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),
};
}
};
10 changes: 10 additions & 0 deletions 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';
135 changes: 135 additions & 0 deletions 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<number>;
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<string>();
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',
}

0 comments on commit 598b72b

Please sign in to comment.