Skip to content

Commit

Permalink
feat(language-service): support fix the component missing member
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
  • Loading branch information
ivanwonder committed Jul 9, 2022
1 parent a003dd8 commit 043ea8b
Show file tree
Hide file tree
Showing 9 changed files with 608 additions and 1 deletion.
10 changes: 10 additions & 0 deletions packages/compiler-cli/src/ngtsc/perf/src/api.ts
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
111 changes: 111 additions & 0 deletions packages/language-service/src/codefixes/fix_missing_member.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* @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 * as ts from 'typescript/lib/tsserverlibrary';

import {getTargetAtPosition, TargetNodeKind} from '../template_target';
import {getTemplateInfoAtPosition} from '../utils';

import {CodeActionMeta, convertTileTextChangeInTcb} from './utils';

const errorCodes = [
(ts as any).Diagnostics.Property_0_does_not_exist_on_type_1_Did_you_mean_2.code,
(ts as any).Diagnostics.Property_0_does_not_exist_on_type_1.code,
];

export const missingMemberMeta: CodeActionMeta = {
errorCodes,
getCodeActions: function(
{templateInfo, start, compiler, formatOptions, preferences, errorCode, tsLs}) {
const target = getTargetAtPosition(templateInfo.template, start);
if (!target) {
return [];
}
let targetStart: number|undefined;
let targetEnd: number|undefined;
if (target.context.kind === TargetNodeKind.RawExpression) {
const span = target.context.node;
if (span instanceof e.PropertyRead) {
targetStart = span.nameSpan.start;
targetEnd = span.nameSpan.end;
}
}
if (targetStart === undefined || targetEnd === undefined) {
return [];
}
const tcb = compiler.getTemplateTypeChecker().getTypeCheckBlock(templateInfo.component);
if (tcb === null) {
return [];
}
const tcbNode = findFirstMatchingNode(tcb, {
filter: (node): node is ts.PropertyAccessExpression => ts.isPropertyAccessExpression(node),
withSpan: new e.AbsoluteSourceSpan(targetStart, targetEnd),
});
if (tcbNode === null) {
return [];
}
const codeActions = tsLs.getCodeFixesAtPosition(
tcb.getSourceFile().fileName, tcbNode.name.getStart(), tcbNode.name.getEnd(), [errorCode],
formatOptions, preferences);
return codeActions.map(codeAction => {
return {
fixName: codeAction.fixName,
fixId: codeAction.fixId,
fixAllDescription: codeAction.fixAllDescription,
description: codeAction.description,
changes: convertTileTextChangeInTcb(codeAction.changes, compiler),
commands: codeAction.commands,
};
});
},
fixIds: ['fixSpelling', 'fixMissingMember'],
getAllCodeActions: function(
{tsLs, scope, fixId, formatOptions, preferences, compiler, diagnostics}) {
let changes: ts.FileTextChanges[] = [];
const seen: Set<ts.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 = changes.concat(combinedCodeActions.changes);
}
return {
changes: convertTileTextChangeInTcb(changes, compiler),
};
}
};
95 changes: 95 additions & 0 deletions packages/language-service/src/codefixes/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* @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 ts from 'typescript/lib/tsserverlibrary';

import {TemplateInfo} from '../utils';

import {missingMemberMeta} from './fix_missing_member';
import {CodeActionMeta, isFixAllUnavailable} from './utils';

export class CodeFixes {
private errorCodeToFixes: Map<number, CodeActionMeta[]> = new Map();
private fixIdToRegistration = new Map<string, CodeActionMeta>();

constructor(private tsLS: ts.LanguageService) {
const allFixesMeta = [missingMemberMeta];
for (const meta of allFixesMeta) {
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);
}
}
}

getCodeActions(
templateInfo: TemplateInfo, compiler: NgCompiler, start: number, end: number,
errorCodes: readonly number[], diagnostics: ts.Diagnostic[],
formatOptions: ts.FormatCodeSettings,
preferences: ts.UserPreferences): readonly ts.CodeFixAction[] {
let codeActions: ts.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 fixAllUnavailable = isFixAllUnavailable(meta.errorCodes, diagnostics);
codeActions = codeActions.concat(
codeActionsForMeta.map(({fixId, fixAllDescription, ...codeActionForMeta}) => {
return fixAllUnavailable ? codeActionForMeta :
{...codeActionForMeta, fixId, fixAllDescription}
}));
}
}
return codeActions;
}

getAllCodeActions(
compiler: NgCompiler, diagnostics: ts.Diagnostic[], scope: ts.CombinedCodeFixScope,
fixId: string, formatOptions: ts.FormatCodeSettings,
preferences: ts.UserPreferences): ts.CombinedCodeActions {
const meta = this.fixIdToRegistration.get(fixId);
if (meta === undefined) {
return {
changes: [],
};
}
return meta.getAllCodeActions({
compiler,
fixId,
formatOptions,
preferences,
tsLs: this.tsLS,
scope,
diagnostics,
});
}
}
107 changes: 107 additions & 0 deletions packages/language-service/src/codefixes/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* @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 ts from 'typescript/lib/tsserverlibrary';

import {TemplateInfo} from '../utils';

export interface CodeActionContext {
templateInfo: TemplateInfo;
compiler: NgCompiler;
start: number;
end: number;
errorCode: number;
formatOptions: ts.FormatCodeSettings;
preferences: ts.UserPreferences;
tsLs: ts.LanguageService;
}

export interface CodeFixAllContext {
scope: ts.CombinedCodeFixScope;
compiler: NgCompiler;
fixId: string;
formatOptions: ts.FormatCodeSettings;
preferences: ts.UserPreferences;
tsLs: ts.LanguageService;
diagnostics: ts.Diagnostic[];
}

export interface CodeActionMeta {
errorCodes: Array<number>;
getCodeActions: (context: CodeActionContext) => readonly ts.CodeFixAction[];
fixIds: string[];
getAllCodeActions: (context: CodeFixAllContext) => ts.CombinedCodeActions;
}

export function convertTileTextChangeInTcb(
changes: readonly ts.FileTextChanges[], compiler: NgCompiler): ts.FileTextChanges[] {
const ttc = compiler.getTemplateTypeChecker();
const fileTextChanges: ts.FileTextChanges[] = [];
for (const fileTextChange of changes) {
if (!ttc.isTrackedTypeCheckFile(absoluteFrom(fileTextChange.fileName))) {
fileTextChanges.push(fileTextChange);
continue;
}
const textChanges: ts.TextChange[] = [];
let fileName: string|undefined;
const seenTextChangeInTemp = 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 (seenTextChangeInTemp.has(changeSpanKey)) {
continue;
}
seenTextChangeInTemp.add(changeSpanKey);
textChanges.push({
newText: textChange.newText,
span: {
start,
length,
},
});
}
if (fileName === undefined) {
continue;
}
fileTextChanges.push({
fileName,
isNewFile: fileTextChange.isNewFile,
textChanges,
});
}
return fileTextChanges;
}

export function isFixAllUnavailable(errorCodes: number[], diagnostics: ts.Diagnostic[]) {
let maybeFixableDiagnostics = 0;
for (const diag of diagnostics) {
if (errorCodes.includes(diag.code)) maybeFixableDiagnostics++;
if (maybeFixableDiagnostics > 1) break;
}

return maybeFixableDiagnostics < 2;
}

0 comments on commit 043ea8b

Please sign in to comment.