Skip to content

Commit 1f067f4

Browse files
devversionAndrewKushnir
authored andcommitted
feat(language-service): add code reactoring action to migrate @Input to signal-input (#57214)
(experimental at this point) Language service refactoring action that can convert `@Input()` declarations to signal inputs. The user can click on an `@Input` property declaration in e.g. the VSCode extension and ask for the input to be migrated. All references, imports and the declaration are updated automatically. PR Close #57214
1 parent 56ee47f commit 1f067f4

File tree

11 files changed

+428
-8
lines changed

11 files changed

+428
-8
lines changed

packages/core/schematics/migrations/signal-migration/src/migration.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ export class SignalInputMigration extends TsurgeComplexMigration<
3838
upgradeAnalysisPhaseToAvoidBatch = false;
3939
upgradedAnalysisPhaseResults: Replacement[] | null = null;
4040

41+
// Necessary for language service configuration.
42+
reportProgressFn: ((percentage: number, updateMessage: string) => void) | null = null;
43+
beforeMigrateHook:
44+
| ((host: MigrationHost, knownInputs: KnownInputs, result: MigrationResult) => void)
45+
| null = null;
46+
4147
bestEffortMode = false;
4248

4349
// Override the default ngtsc program creation, to add extra flags.
@@ -70,7 +76,10 @@ export class SignalInputMigration extends TsurgeComplexMigration<
7076
const result = new MigrationResult();
7177
const host = createMigrationHost(info);
7278

79+
this.reportProgressFn?.(10, 'Analyzing project (input usages)..');
7380
const {inheritanceGraph} = executeAnalysisPhase(host, knownInputs, result, analysisDeps);
81+
82+
this.reportProgressFn?.(40, 'Checking inheritance..');
7483
pass4__checkInheritanceOfInputs(host, inheritanceGraph, metaRegistry, knownInputs);
7584
if (this.bestEffortMode) {
7685
filterIncompatibilitiesForBestEffortMode(knownInputs);
@@ -81,13 +90,16 @@ export class SignalInputMigration extends TsurgeComplexMigration<
8190
// Non-batch mode!
8291
if (this.upgradeAnalysisPhaseToAvoidBatch) {
8392
const merged = await this.merge([unitData]);
93+
94+
this.reportProgressFn?.(60, 'Collecting migration changes..');
8495
const replacements = await this.migrate(merged, info, {
8596
knownInputs,
8697
result,
8798
host,
8899
inheritanceGraph,
89100
analysisDeps,
90101
});
102+
this.reportProgressFn?.(100, 'Completed migration.');
91103

92104
// Expose the upgraded analysis stage results.
93105
this.upgradedAnalysisPhaseResults = replacements;
@@ -122,15 +134,21 @@ export class SignalInputMigration extends TsurgeComplexMigration<
122134
const analysisRes = executeAnalysisPhase(host, knownInputs, result, analysisDeps);
123135
inheritanceGraph = analysisRes.inheritanceGraph;
124136
populateKnownInputsFromGlobalData(knownInputs, globalMetadata);
125-
} else {
126-
inheritanceGraph = nonBatchData.inheritanceGraph;
127-
}
128137

129-
pass4__checkInheritanceOfInputs(host, inheritanceGraph, analysisDeps.metaRegistry, knownInputs);
130-
if (this.bestEffortMode) {
131-
filterIncompatibilitiesForBestEffortMode(knownInputs);
138+
pass4__checkInheritanceOfInputs(
139+
host,
140+
inheritanceGraph,
141+
analysisDeps.metaRegistry,
142+
knownInputs,
143+
);
144+
if (this.bestEffortMode) {
145+
filterIncompatibilitiesForBestEffortMode(knownInputs);
146+
}
132147
}
133148

149+
// Optional before migrate hook. Used by the language service.
150+
this.beforeMigrateHook?.(host, knownInputs, result);
151+
134152
executeMigrationPhase(host, knownInputs, result, analysisDeps);
135153

136154
return result.replacements;

packages/core/schematics/utils/tsurge/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ ts_library(
55
srcs = glob(["**/*.ts"]),
66
visibility = [
77
"//packages/core/schematics:__subpackages__",
8+
"//packages/language-service/src/refactorings:__pkg__",
89
],
910
deps = [
1011
"//packages/compiler-cli",

packages/language-service/src/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ ts_library(
2525
"//packages/compiler-cli/src/ngtsc/typecheck",
2626
"//packages/compiler-cli/src/ngtsc/typecheck/api",
2727
"//packages/compiler-cli/src/ngtsc/util",
28+
"//packages/core/schematics/migrations/signal-migration/src",
2829
"//packages/language-service:api",
2930
"//packages/language-service/src/refactorings",
3031
"//packages/language-service/src/utils",

packages/language-service/src/language_service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,7 @@ export class LanguageService {
577577
return this.withCompilerAndPerfTracing(PerfPhase.LSApplyRefactoring, (compiler) => {
578578
return matchingRefactoring.computeEditsForFix(
579579
compiler,
580+
this.options,
580581
fileName,
581582
positionOrRange,
582583
reportProgress,
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
load("//tools:defaults.bzl", "ts_library")
2+
3+
package(default_visibility = ["//packages/language-service:__subpackages__"])
4+
5+
ts_library(
6+
name = "refactorings",
7+
srcs = glob([
8+
"*.ts",
9+
]),
10+
deps = [
11+
"//packages/compiler-cli",
12+
"//packages/compiler-cli/src/ngtsc/core",
13+
"//packages/compiler-cli/src/ngtsc/metadata",
14+
"//packages/core/schematics/migrations/signal-migration/src",
15+
"//packages/core/schematics/utils/tsurge",
16+
"//packages/language-service:api",
17+
"//packages/language-service/src/utils",
18+
"@npm//@types/node",
19+
"@npm//typescript",
20+
],
21+
)
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {CompilerOptions} from '@angular/compiler-cli';
10+
import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
11+
import {MetaKind} from '@angular/compiler-cli/src/ngtsc/metadata';
12+
import {
13+
ClassIncompatibilityReason,
14+
InputIncompatibilityReason,
15+
} from '@angular/core/schematics/migrations/signal-migration/src/input_detection/incompatibility';
16+
import {ApplyRefactoringProgressFn} from '@angular/language-service/api';
17+
import ts from 'typescript';
18+
import {
19+
InputNode,
20+
isInputContainerNode,
21+
} from '../../../core/schematics/migrations/signal-migration/src/input_detection/input_node';
22+
import {KnownInputInfo} from '../../../core/schematics/migrations/signal-migration/src/input_detection/known_inputs';
23+
import {SignalInputMigration} from '../../../core/schematics/migrations/signal-migration/src/migration';
24+
import {
25+
getInputDescriptor,
26+
isInputDescriptor,
27+
} from '../../../core/schematics/migrations/signal-migration/src/utils/input_id';
28+
import {groupReplacementsByFile} from '../../../core/schematics/utils/tsurge/helpers/group_replacements';
29+
import {findTightestNode, getParentClassDeclaration} from '../utils/ts_utils';
30+
import {isTypeScriptFile} from '../utils';
31+
import type {Refactoring} from './refactoring';
32+
33+
/**
34+
* Language service refactoring action that can convert `@Input()`
35+
* declarations to signal inputs.
36+
*
37+
* The user can click on an `@Input` property declaration in e.g. the VSCode
38+
* extension and ask for the input to be migrated. All references, imports and
39+
* the declaration are updated automatically.
40+
*/
41+
export class ConvertToSignalInputRefactoring implements Refactoring {
42+
id = 'convert-to-signal-input';
43+
description = '(experimental fixer): Convert @Input() to a signal input';
44+
45+
migration: SignalInputMigration | null = null;
46+
47+
isApplicable(
48+
compiler: NgCompiler,
49+
fileName: string,
50+
positionOrRange: number | ts.TextRange,
51+
): boolean {
52+
if (!isTypeScriptFile(fileName)) {
53+
return false;
54+
}
55+
56+
const sf = compiler.getCurrentProgram().getSourceFile(fileName);
57+
if (sf === undefined) {
58+
return false;
59+
}
60+
61+
const start = typeof positionOrRange === 'number' ? positionOrRange : positionOrRange.pos;
62+
const node = findTightestNode(sf, start);
63+
if (node === undefined) {
64+
return false;
65+
}
66+
67+
const classDecl = getParentClassDeclaration(node);
68+
if (classDecl === undefined) {
69+
return false;
70+
}
71+
72+
const meta = compiler.getMeta(classDecl);
73+
if (meta === undefined || meta?.kind !== MetaKind.Directive) {
74+
return false;
75+
}
76+
77+
const containingProp = findParentPropertyDeclaration(node);
78+
if (containingProp === null) {
79+
return false;
80+
}
81+
if (!ts.isIdentifier(containingProp.name) && !ts.isStringLiteralLike(containingProp.name)) {
82+
return false;
83+
}
84+
85+
const inputMeta = meta.inputs.getByClassPropertyName(containingProp.name.text);
86+
if (inputMeta === null || inputMeta.isSignal) {
87+
return false;
88+
}
89+
return true;
90+
}
91+
92+
async computeEditsForFix(
93+
compiler: NgCompiler,
94+
compilerOptions: CompilerOptions,
95+
fileName: string,
96+
positionOrRange: number | ts.TextRange,
97+
reportProgress: ApplyRefactoringProgressFn,
98+
): Promise<ts.RefactorEditInfo> {
99+
const sf = compiler.getCurrentProgram().getSourceFile(fileName);
100+
if (sf === undefined) {
101+
return {edits: []};
102+
}
103+
104+
const start = typeof positionOrRange === 'number' ? positionOrRange : positionOrRange.pos;
105+
const node = findTightestNode(sf, start);
106+
if (node === undefined) {
107+
return {edits: []};
108+
}
109+
110+
const containingProp = findParentPropertyDeclaration(node);
111+
if (containingProp === null || !isInputContainerNode(containingProp)) {
112+
return {edits: [], notApplicableReason: 'Not an input property.'};
113+
}
114+
reportProgress(0, 'Starting input migration. Analyzing..');
115+
116+
// TS incorrectly narrows to `null` if we don't explicitly widen the type.
117+
// See: https://github.com/microsoft/TypeScript/issues/11498.
118+
let targetInput: KnownInputInfo | null = null as KnownInputInfo | null;
119+
120+
this.migration ??= new SignalInputMigration();
121+
this.migration.upgradeAnalysisPhaseToAvoidBatch = true;
122+
this.migration.reportProgressFn = reportProgress;
123+
this.migration.beforeMigrateHook = getBeforeMigrateHookToFilterAllUnrelatedInputs(
124+
containingProp,
125+
(i) => (targetInput = i),
126+
);
127+
128+
await this.migration.analyze(
129+
this.migration.prepareProgram({
130+
ngCompiler: compiler,
131+
program: compiler.getCurrentProgram(),
132+
userOptions: compilerOptions,
133+
programAbsoluteRootPaths: [],
134+
tsconfigAbsolutePath: '',
135+
}),
136+
);
137+
138+
if (this.migration.upgradedAnalysisPhaseResults === null || targetInput === null) {
139+
return {
140+
edits: [],
141+
notApplicableReason: 'Unexpected error. No edits could be computed.',
142+
};
143+
}
144+
145+
// Check for incompatibility, and report if it prevented migration.
146+
if (targetInput.isIncompatible()) {
147+
const {container, descriptor} = targetInput;
148+
const memberIncompatibility = container.memberIncompatibility.get(descriptor.key);
149+
const classIncompatibility = container.incompatible;
150+
151+
return {
152+
edits: [],
153+
// TODO: Output a better human-readable message here. For now this is better than a noop.
154+
notApplicableReason: `Input cannot be migrated: ${
155+
memberIncompatibility !== undefined
156+
? InputIncompatibilityReason[memberIncompatibility.reason]
157+
: classIncompatibility !== null
158+
? ClassIncompatibilityReason[classIncompatibility]
159+
: 'unknown'
160+
}`,
161+
};
162+
}
163+
164+
const edits: ts.FileTextChanges[] = Array.from(
165+
groupReplacementsByFile(this.migration.upgradedAnalysisPhaseResults).entries(),
166+
).map(([fileName, changes]) => {
167+
return {
168+
fileName,
169+
textChanges: changes.map((c) => ({
170+
newText: c.data.toInsert,
171+
span: {
172+
start: c.data.position,
173+
length: c.data.end - c.data.position,
174+
},
175+
})),
176+
};
177+
});
178+
179+
if (edits.length === 0) {
180+
return {
181+
edits: [],
182+
notApplicableReason: 'No edits were generated. Consider reporting this as a bug.',
183+
};
184+
}
185+
186+
return {edits};
187+
}
188+
}
189+
190+
function findParentPropertyDeclaration(node: ts.Node): ts.PropertyDeclaration | null {
191+
while (!ts.isPropertyDeclaration(node) && !ts.isSourceFile(node)) {
192+
node = node.parent;
193+
}
194+
if (ts.isSourceFile(node)) {
195+
return null;
196+
}
197+
return node;
198+
}
199+
200+
function getBeforeMigrateHookToFilterAllUnrelatedInputs(
201+
containingProp: InputNode,
202+
setTargetInput: (i: KnownInputInfo) => void,
203+
): SignalInputMigration['beforeMigrateHook'] {
204+
return (host, knownInputs, result) => {
205+
const {key} = getInputDescriptor(host, containingProp);
206+
const targetInput = knownInputs.get({key});
207+
208+
if (targetInput === undefined) {
209+
return;
210+
}
211+
212+
setTargetInput(targetInput);
213+
214+
// Mark all other inputs as incompatible.
215+
// Note that we still analyzed the whole application for potential references.
216+
// Only migrate references to the target input.
217+
for (const input of result.sourceInputs.keys()) {
218+
if (input.key !== key) {
219+
knownInputs.markInputAsIncompatible(input, {
220+
context: null,
221+
reason: InputIncompatibilityReason.IgnoredBecauseOfLanguageServiceRefactoringRange,
222+
});
223+
}
224+
}
225+
226+
result.references = result.references.filter(
227+
// Note: References to the whole class are not migrated as we are not migrating all inputs.
228+
// We can revisit this at a later time.
229+
(r) => isInputDescriptor(r.target) && r.target.key === key,
230+
);
231+
};
232+
}

packages/language-service/src/refactorings/refactoring.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
1010
import ts from 'typescript';
1111
import {ApplyRefactoringProgressFn} from '@angular/language-service/api';
12+
import {CompilerOptions} from '@angular/compiler-cli';
13+
import {ConvertToSignalInputRefactoring} from './convert_to_signal_input';
1214

1315
/**
1416
* Interface that describes a refactoring.
@@ -37,10 +39,11 @@ export interface Refactoring {
3739
/** Computes the edits for the refactoring. */
3840
computeEditsForFix(
3941
compiler: NgCompiler,
42+
compilerOptions: CompilerOptions,
4043
fileName: string,
4144
positionOrRange: number | ts.TextRange,
4245
reportProgress: ApplyRefactoringProgressFn,
4346
): Promise<ts.RefactorEditInfo>;
4447
}
4548

46-
export const allRefactorings: Refactoring[] = [];
49+
export const allRefactorings: Refactoring[] = [new ConvertToSignalInputRefactoring()];

0 commit comments

Comments
 (0)