feat(language-service): support fix the component missing member (#46764)

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 https://github.com/angular/vscode-ng-language-service/issues/1610

PR Close #46764
This commit is contained in:
ivanwonder
2022-07-03 23:11:17 +08:00
committed by Andrew Kushnir
parent d8cf78ba5e
commit 598b72bd05
12 changed files with 799 additions and 30 deletions
@@ -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,
}
/**
+4 -1
View File
@@ -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",
@@ -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];
@@ -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,
});
}
}
@@ -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),
};
}
};
@@ -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';
@@ -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',
}
@@ -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<readonly ts.CodeFixAction[]>(
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<ts.CombinedCodeActions>(
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<GetComponentLocationsForTemplateResponse>(
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<ParseSourceSpan|AbsoluteSourceSpan>;
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);
const sf = selectionNodesInfo.componentTcbNode.getSourceFile();
selections = selectionNodes.map(n => {
return {
start: n.getStart(sf),
length: n.getEnd() - n.getStart(sf),
};
});
}
const selections = selectionNodesInfo.nodes.map(n => {
return {
start: n.getStart(sf),
length: n.getEnd() - n.getStart(sf),
};
});
return {
fileName: sf.fileName,
@@ -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 tss from 'typescript/lib/tsserverlibrary';
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,69 @@ export function getTargetAtPosition(template: t.Node[], position: number): Templ
return {position, context: nodeInContext, template: context, parent};
}
function findFirstMatchingNodeForSourceSpan(
tcb: tss.Node, sourceSpan: ParseSourceSpan|e.AbsoluteSourceSpan) {
return findFirstMatchingNode(
tcb,
{
withSpan: sourceSpan,
filter: (node: tss.Node): node is tss.Node => true,
},
);
}
/**
* A tcb nodes for the template at a given position, include the tcb node of the template.
*/
interface TcbNodesInfoForTemplate {
componentTcbNode: tss.Node;
nodes: tss.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 tcbNodes: (tss.Node|null)[] = [];
if (target.context.kind === TargetNodeKind.RawExpression) {
const targetNode = target.context.node;
if (targetNode instanceof e.PropertyRead) {
const tsNode = findFirstMatchingNode(tcb, {
withSpan: targetNode.nameSpan,
filter: (node): node is tss.PropertyAccessExpression => tss.isPropertyAccessExpression(node)
});
tcbNodes.push(tsNode?.name ?? null);
} else {
tcbNodes.push(findFirstMatchingNodeForSourceSpan(tcb, target.context.node.sourceSpan));
}
} else if (target.context.kind === TargetNodeKind.TwoWayBindingContext) {
const targetNodes = target.context.nodes.map(n => n.sourceSpan).map((node) => {
return findFirstMatchingNodeForSourceSpan(tcb, node);
});
tcbNodes.push(...targetNodes);
} else {
tcbNodes.push(findFirstMatchingNodeForSourceSpan(tcb, target.context.node.sourceSpan));
}
return {
nodes: tcbNodes.filter((n): n is tss.Node => n !== null),
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
@@ -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,
};
}
@@ -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': `<div *ngIf="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]);
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;
}
@@ -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) {