diff --git a/packages/compiler-cli/src/ngtsc/annotations/src/component.ts b/packages/compiler-cli/src/ngtsc/annotations/src/component.ts index 58b4110543272..9e00e0cc9261c 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/src/component.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/src/component.ts @@ -16,7 +16,7 @@ import {ImportedFile, ModuleResolver, Reference, ReferenceEmitter} from '../../i import {DependencyTracker} from '../../incremental/api'; import {extractSemanticTypeParameters, isArrayEqual, isReferenceEqual, SemanticDepGraphUpdater, SemanticReference, SemanticSymbol} from '../../incremental/semantic_graph'; import {IndexingContext} from '../../indexer'; -import {ClassPropertyMapping, ComponentResources, DirectiveMeta, DirectiveTypeCheckMeta, extractDirectiveTypeCheckMeta, InjectableClassRegistry, MetadataReader, MetadataRegistry, Resource, ResourceRegistry} from '../../metadata'; +import {ClassPropertyMapping, ComponentResources, DirectiveMeta, DirectiveTypeCheckMeta, extractDirectiveTypeCheckMeta, InjectableClassRegistry, MetadataReader, MetadataRegistry, MetaType, Resource, ResourceRegistry} from '../../metadata'; import {EnumValue, PartialEvaluator, ResolvedValue} from '../../partial_evaluator'; import {PerfEvent, PerfRecorder} from '../../perf'; import {ClassDeclaration, DeclarationNode, Decorator, ReflectionHost, reflectObjectLiteral} from '../../reflection'; @@ -524,6 +524,7 @@ export class ComponentDecoratorHandler implements // the information about the component is available during the compile() phase. const ref = new Reference(node); this.metaRegistry.registerDirectiveMetadata({ + type: MetaType.Directive, ref, name: node.name.text, selector: analysis.meta.selector, diff --git a/packages/compiler-cli/src/ngtsc/annotations/src/directive.ts b/packages/compiler-cli/src/ngtsc/annotations/src/directive.ts index 3893ad0725d5a..c9a4f2a831709 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/src/directive.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/src/directive.ts @@ -13,7 +13,7 @@ import * as ts from 'typescript'; import {ErrorCode, FatalDiagnosticError} from '../../diagnostics'; import {Reference} from '../../imports'; import {areTypeParametersEqual, extractSemanticTypeParameters, isArrayEqual, isSetEqual, isSymbolEqual, SemanticDepGraphUpdater, SemanticSymbol, SemanticTypeParameter} from '../../incremental/semantic_graph'; -import {BindingPropertyName, ClassPropertyMapping, ClassPropertyName, DirectiveTypeCheckMeta, InjectableClassRegistry, MetadataReader, MetadataRegistry, TemplateGuardMeta} from '../../metadata'; +import {BindingPropertyName, ClassPropertyMapping, ClassPropertyName, DirectiveTypeCheckMeta, InjectableClassRegistry, MetadataReader, MetadataRegistry, MetaType, TemplateGuardMeta} from '../../metadata'; import {extractDirectiveTypeCheckMeta} from '../../metadata/src/util'; import {DynamicValue, EnumValue, PartialEvaluator} from '../../partial_evaluator'; import {PerfEvent, PerfRecorder} from '../../perf'; @@ -256,6 +256,7 @@ export class DirectiveDecoratorHandler implements // the information about the directive is available during the compile() phase. const ref = new Reference(node); this.metaRegistry.registerDirectiveMetadata({ + type: MetaType.Directive, ref, name: node.name.text, selector: analysis.meta.selector, diff --git a/packages/compiler-cli/src/ngtsc/annotations/src/pipe.ts b/packages/compiler-cli/src/ngtsc/annotations/src/pipe.ts index 1dd0d4b961271..f623bfaf6a481 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/src/pipe.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/src/pipe.ts @@ -12,7 +12,7 @@ import * as ts from 'typescript'; import {ErrorCode, FatalDiagnosticError} from '../../diagnostics'; import {Reference} from '../../imports'; import {SemanticSymbol} from '../../incremental/semantic_graph'; -import {InjectableClassRegistry, MetadataRegistry} from '../../metadata'; +import {InjectableClassRegistry, MetadataRegistry, MetaType} from '../../metadata'; import {PartialEvaluator} from '../../partial_evaluator'; import {PerfEvent, PerfRecorder} from '../../perf'; import {ClassDeclaration, Decorator, ReflectionHost, reflectObjectLiteral} from '../../reflection'; @@ -27,6 +27,7 @@ import {compileResults, findAngularDecorator, getValidConstructorDependencies, m export interface PipeHandlerData { meta: R3PipeMetadata; classMetadata: R3ClassMetadata|null; + pipeNameExpr: ts.Expression; } /** @@ -134,6 +135,7 @@ export class PipeDecoratorHandler implements pure, }, classMetadata: extractClassMetadata(clazz, this.reflector, this.isCore), + pipeNameExpr, }, }; } @@ -144,7 +146,8 @@ export class PipeDecoratorHandler implements register(node: ClassDeclaration, analysis: Readonly): void { const ref = new Reference(node); - this.metaRegistry.registerPipeMetadata({ref, name: analysis.meta.pipeName}); + this.metaRegistry.registerPipeMetadata( + {type: MetaType.Pipe, ref, name: analysis.meta.pipeName, nameExpr: analysis.pipeNameExpr}); this.injectableRegistry.registerInjectable(node); } diff --git a/packages/compiler-cli/src/ngtsc/core/src/compiler.ts b/packages/compiler-cli/src/ngtsc/core/src/compiler.ts index 90352bf11cea6..f33a8691624d3 100644 --- a/packages/compiler-cli/src/ngtsc/core/src/compiler.ts +++ b/packages/compiler-cli/src/ngtsc/core/src/compiler.ts @@ -18,7 +18,7 @@ import {AbsoluteModuleStrategy, AliasingHost, AliasStrategy, DefaultImportTracke import {IncrementalBuildStrategy, IncrementalCompilation, IncrementalState} from '../../incremental'; import {SemanticSymbol} from '../../incremental/semantic_graph'; import {generateAnalysis, IndexedComponent, IndexingContext} from '../../indexer'; -import {ComponentResources, CompoundMetadataReader, CompoundMetadataRegistry, DtsMetadataReader, InjectableClassRegistry, LocalMetadataRegistry, MetadataReader, ResourceRegistry} from '../../metadata'; +import {ComponentResources, CompoundMetadataReader, CompoundMetadataRegistry, DirectiveMeta, DtsMetadataReader, InjectableClassRegistry, LocalMetadataRegistry, MetadataReader, PipeMeta, ResourceRegistry} from '../../metadata'; import {ModuleWithProvidersScanner} from '../../modulewithproviders'; import {PartialEvaluator} from '../../partial_evaluator'; import {ActivePerfRecorder, DelegatingPerfRecorder, PerfCheckpoint, PerfEvent, PerfPhase} from '../../perf'; @@ -528,6 +528,19 @@ export class NgCompiler { return {styles, template}; } + getMeta(classDecl: DeclarationNode): PipeMeta|DirectiveMeta|null { + if (!isNamedClassDeclaration(classDecl)) { + return null; + } + const ref = new Reference(classDecl); + const {metaReader} = this.ensureAnalyzed(); + const meta = metaReader.getPipeMetadata(ref) ?? metaReader.getDirectiveMetadata(ref); + if (meta === null) { + return null; + } + return meta; + } + /** * Perform Angular's analysis step (as a precursor to `getDiagnostics` or `prepareEmit`) * asynchronously. diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/api.ts b/packages/compiler-cli/src/ngtsc/metadata/src/api.ts index 0b48090de97bd..d686e2faab463 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/src/api.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/src/api.ts @@ -80,10 +80,17 @@ export interface DirectiveTypeCheckMeta { isGeneric: boolean; } +export enum MetaType { + Pipe, + Directive, +} + /** * Metadata collected for a directive within an NgModule's scope. */ export interface DirectiveMeta extends T2DirectiveMeta, DirectiveTypeCheckMeta { + type: MetaType.Directive; + ref: Reference; /** * Unparsed selector of the directive, or null if the directive does not have a selector. @@ -144,8 +151,10 @@ export interface TemplateGuardMeta { * Metadata for a pipe within an NgModule's scope. */ export interface PipeMeta { + type: MetaType.Pipe; ref: Reference; name: string; + nameExpr: ts.Expression|null; } /** diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/dts.ts b/packages/compiler-cli/src/ngtsc/metadata/src/dts.ts index d88bf1a2966f4..497d2e900ff63 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/src/dts.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/src/dts.ts @@ -11,7 +11,7 @@ import * as ts from 'typescript'; import {Reference} from '../../imports'; import {ClassDeclaration, isNamedClassDeclaration, ReflectionHost, TypeValueReferenceKind} from '../../reflection'; -import {DirectiveMeta, MetadataReader, NgModuleMeta, PipeMeta} from './api'; +import {DirectiveMeta, MetadataReader, MetaType, NgModuleMeta, PipeMeta} from './api'; import {ClassPropertyMapping} from './property_mapping'; import {extractDirectiveTypeCheckMeta, extractReferencesFromType, readStringArrayType, readStringMapType, readStringType} from './util'; @@ -95,6 +95,7 @@ export class DtsMetadataReader implements MetadataReader { const outputs = ClassPropertyMapping.fromMappedObject(readStringMapType(def.type.typeArguments[4])); return { + type: MetaType.Directive, ref, name: clazz.name.text, isComponent, @@ -131,7 +132,12 @@ export class DtsMetadataReader implements MetadataReader { return null; } const name = type.literal.text; - return {ref, name}; + return { + type: MetaType.Pipe, + ref, + name, + nameExpr: null, + }; } } diff --git a/packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts b/packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts index 38a8ad79dda3f..0aa1d0c49f0a0 100644 --- a/packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts +++ b/packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts @@ -9,7 +9,7 @@ import * as ts from 'typescript'; import {Reference, ReferenceEmitter} from '../../imports'; -import {ClassPropertyMapping, CompoundMetadataRegistry, DirectiveMeta, LocalMetadataRegistry, MetadataRegistry, PipeMeta} from '../../metadata'; +import {ClassPropertyMapping, CompoundMetadataRegistry, DirectiveMeta, LocalMetadataRegistry, MetadataRegistry, MetaType, PipeMeta} from '../../metadata'; import {ClassDeclaration} from '../../reflection'; import {ScopeData} from '../src/api'; import {DtsModuleScopeResolver} from '../src/dependency'; @@ -232,6 +232,7 @@ describe('LocalModuleScopeRegistry', () => { function fakeDirective(ref: Reference): DirectiveMeta { const name = ref.debugName!; return { + type: MetaType.Directive, ref, name, selector: `[${ref.debugName}]`, @@ -255,7 +256,7 @@ function fakeDirective(ref: Reference): DirectiveMeta { function fakePipe(ref: Reference): PipeMeta { const name = ref.debugName!; - return {ref, name}; + return {type: MetaType.Pipe, ref, name, nameExpr: null}; } class MockDtsModuleScopeResolver implements DtsModuleScopeResolver { diff --git a/packages/compiler-cli/src/ngtsc/typecheck/api/symbols.ts b/packages/compiler-cli/src/ngtsc/typecheck/api/symbols.ts index 0640ac103b9e8..1481d6301c578 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/api/symbols.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/api/symbols.ts @@ -307,4 +307,4 @@ export interface ClassSymbol { /** The position for the variable declaration for the class instance. */ shimLocation: ShimLocation; -} +} \ No newline at end of file diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts index cd7bc32c27b07..904970050205d 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts @@ -430,19 +430,14 @@ export class SymbolBuilder { } private getSymbolOfPipe(expression: BindingPipe): PipeSymbol|null { - const node = findFirstMatchingNode( - this.typeCheckBlock, {withSpan: expression.sourceSpan, filter: ts.isCallExpression}); - if (node === null || !ts.isPropertyAccessExpression(node.expression)) { + const methodAccess = findFirstMatchingNode( + this.typeCheckBlock, + {withSpan: expression.nameSpan, filter: ts.isPropertyAccessExpression}); + if (methodAccess === null) { return null; } - const methodAccess = node.expression; - // Find the node for the pipe variable from the transform property access. This will be one of - // two forms: `_pipe1.transform` or `(_pipe1 as any).transform`. - const pipeVariableNode = ts.isParenthesizedExpression(methodAccess.expression) && - ts.isAsExpression(methodAccess.expression.expression) ? - methodAccess.expression.expression.expression : - methodAccess.expression; + const pipeVariableNode = methodAccess.expression; const pipeDeclaration = this.getTypeChecker().getSymbolAtLocation(pipeVariableNode); if (pipeDeclaration === undefined || pipeDeclaration.valueDeclaration === undefined) { return null; diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts index cd62b16c39f33..47ad813ae3fcd 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts @@ -1712,17 +1712,19 @@ class TcbExpressionTranslator { // Use an 'any' value to at least allow the rest of the expression to be checked. pipe = NULL_AS_ANY; - } else if (this.tcb.env.config.checkTypeOfPipes) { + } else { // Use a variable declared as the pipe's type. pipe = this.tcb.env.pipeInst(pipeRef); - } else { - // Use an 'any' value when not checking the type of the pipe. - pipe = ts.createAsExpression( - this.tcb.env.pipeInst(pipeRef), ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword)); } const args = ast.args.map(arg => this.translate(arg)); - const methodAccess = ts.createPropertyAccess(pipe, 'transform'); + let methodAccess: ts.Expression = + ts.factory.createPropertyAccessExpression(pipe, 'transform'); addParseSpanInfo(methodAccess, ast.nameSpan); + if (!this.tcb.env.config.checkTypeOfPipes) { + methodAccess = ts.factory.createAsExpression( + methodAccess, ts.factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword)); + } + const result = ts.createCall( /* expression */ methodAccess, /* typeArguments */ undefined, diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts index 34bbc260233d0..ae6545a127b31 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts @@ -13,7 +13,7 @@ import {absoluteFrom, AbsoluteFsPath, getSourceFileOrError, LogicalFileSystem} f import {TestFile} from '../../file_system/testing'; import {AbsoluteModuleStrategy, LocalIdentifierStrategy, LogicalProjectStrategy, ModuleResolver, Reexport, Reference, ReferenceEmitter, RelativePathStrategy} from '../../imports'; import {NOOP_INCREMENTAL_BUILD} from '../../incremental'; -import {ClassPropertyMapping, CompoundMetadataReader} from '../../metadata'; +import {ClassPropertyMapping, CompoundMetadataReader, MetaType} from '../../metadata'; import {NOOP_PERF_RECORDER} from '../../perf'; import {TsCreateProgramDriver} from '../../program_driver'; import {ClassDeclaration, isNamedClassDeclaration, TypeScriptReflectionHost} from '../../reflection'; @@ -595,6 +595,7 @@ function makeScope(program: ts.Program, sf: ts.SourceFile, decls: TestDeclaratio if (decl.type === 'directive') { scope.directives.push({ + type: MetaType.Directive, ref: new Reference(declClass), baseClass: null, name: decl.name, @@ -618,8 +619,10 @@ function makeScope(program: ts.Program, sf: ts.SourceFile, decls: TestDeclaratio }); } else if (decl.type === 'pipe') { scope.pipes.push({ + type: MetaType.Pipe, ref: new Reference(declClass), name: decl.pipeName, + nameExpr: null, }); } } diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts index b15735bb9060e..ce6ba8fd1beea 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts @@ -992,7 +992,7 @@ describe('type check blocks', () => { const DISABLED_CONFIG: TypeCheckingConfig = {...BASE_CONFIG, checkTypeOfPipes: false}; const block = tcb(TEMPLATE, PIPES, DISABLED_CONFIG); expect(block).toContain('var _pipe1: i0.TestPipe = null!;'); - expect(block).toContain('((_pipe1 as any).transform(((ctx).a), ((ctx).b), ((ctx).c)));'); + expect(block).toContain('((_pipe1.transform as any)(((ctx).a), ((ctx).b), ((ctx).c))'); }); }); diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker__get_symbol_of_template_node_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker__get_symbol_of_template_node_spec.ts index f08ad4b72e6ad..9fec2bbda4f45 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker__get_symbol_of_template_node_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker__get_symbol_of_template_node_spec.ts @@ -722,29 +722,19 @@ runInEachFileSystem(() => { BindingPipe; } - it('should get symbol for pipe', () => { - setupPipesTest(); - const pipeSymbol = templateTypeChecker.getSymbolOfNode(binding, cmp)!; - assertPipeSymbol(pipeSymbol); - expect(program.getTypeChecker().symbolToString(pipeSymbol.tsSymbol!)) - .toEqual('transform'); - expect(program.getTypeChecker().symbolToString(pipeSymbol.classSymbol.tsSymbol)) - .toEqual('TestPipe'); - expect(program.getTypeChecker().typeToString(pipeSymbol.tsType!)) - .toEqual('(value: string, repeat: number, commaSeparate: boolean) => string[]'); - }); - - it('should get symbol for pipe, checkTypeOfPipes: false', () => { - setupPipesTest(false); - const pipeSymbol = templateTypeChecker.getSymbolOfNode(binding, cmp)! as PipeSymbol; - assertPipeSymbol(pipeSymbol); - expect(pipeSymbol.tsSymbol).toBeNull(); - expect(program.getTypeChecker().typeToString(pipeSymbol.tsType!)).toEqual('any'); - expect(program.getTypeChecker().symbolToString(pipeSymbol.classSymbol.tsSymbol)) - .toEqual('TestPipe'); - expect(program.getTypeChecker().typeToString(pipeSymbol.classSymbol.tsType)) - .toEqual('TestPipe'); - }); + for (const checkTypeOfPipes of [true, false]) { + it(`should get symbol for pipe, checkTypeOfPipes: ${checkTypeOfPipes}`, () => { + setupPipesTest(checkTypeOfPipes); + const pipeSymbol = templateTypeChecker.getSymbolOfNode(binding, cmp)!; + assertPipeSymbol(pipeSymbol); + expect(program.getTypeChecker().symbolToString(pipeSymbol.tsSymbol!)) + .toEqual('transform'); + expect(program.getTypeChecker().symbolToString(pipeSymbol.classSymbol.tsSymbol)) + .toEqual('TestPipe'); + expect(program.getTypeChecker().typeToString(pipeSymbol.tsType!)) + .toEqual('(value: string, repeat: number, commaSeparate: boolean) => string[]'); + }); + } it('should get symbols for pipe expression and args', () => { setupPipesTest(false); diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/GOLDEN_PARTIAL.js b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/GOLDEN_PARTIAL.js index ec7381e4d5b18..7c28109ba7bf3 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/GOLDEN_PARTIAL.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/GOLDEN_PARTIAL.js @@ -71,6 +71,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE }] }] }); class MyForwardPipe { + transform() { } } MyForwardPipe.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyForwardPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); MyForwardPipe.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyForwardPipe, name: "my_forward_pipe" }); diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/forward_referenced_pipe.ts b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/forward_referenced_pipe.ts index aa1a1a8b76b18..c127944498eab 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/forward_referenced_pipe.ts +++ b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/forward_referenced_pipe.ts @@ -11,6 +11,7 @@ export class HostBindingComp { @Pipe({name: 'my_forward_pipe'}) class MyForwardPipe { + transform() {} } @NgModule({declarations: [HostBindingComp, MyForwardPipe]}) diff --git a/packages/compiler-cli/test/ngtsc/incremental_semantic_changes_spec.ts b/packages/compiler-cli/test/ngtsc/incremental_semantic_changes_spec.ts index c6b466c5b3add..42db9c4c66c17 100644 --- a/packages/compiler-cli/test/ngtsc/incremental_semantic_changes_spec.ts +++ b/packages/compiler-cli/test/ngtsc/incremental_semantic_changes_spec.ts @@ -1361,7 +1361,9 @@ runInEachFileSystem(() => { @Pipe({ name: 'dep', }) - export class DepB {} + export class DepB { + transform() {} + } `); env.write('module.ts', ` import {NgModule} from '@angular/core'; @@ -1385,7 +1387,9 @@ runInEachFileSystem(() => { @Pipe({ name: 'dep', }) - export class DepA {} + export class DepA { + transform() {} + } @Directive({ selector: 'dep', diff --git a/packages/compiler-cli/test/ngtsc/incremental_spec.ts b/packages/compiler-cli/test/ngtsc/incremental_spec.ts index 9f6c4f56e19e3..75780f19a8833 100644 --- a/packages/compiler-cli/test/ngtsc/incremental_spec.ts +++ b/packages/compiler-cli/test/ngtsc/incremental_spec.ts @@ -200,7 +200,9 @@ runInEachFileSystem(() => { import {Pipe} from '@angular/core'; @Pipe({name: 'myPipe'}) - export class MyPipe {} + export class MyPipe { + transform() {} + } `); env.write('module.ts', ` import {NgModule, NO_ERRORS_SCHEMA} from '@angular/core'; @@ -238,7 +240,9 @@ runInEachFileSystem(() => { import {Pipe} from '@angular/core'; @Pipe({name: 'foo_changed'}) - export class FooPipe {} + export class FooPipe { + transform() {} + } `); env.driveMain(); const written = env.getFilesWrittenSinceLastFlush(); @@ -910,7 +914,9 @@ runInEachFileSystem(() => { import {Pipe} from '@angular/core'; @Pipe({name: 'foo'}) - export class FooPipe {} + export class FooPipe { + transform() {} + } `); env.write('foo_module.ts', ` import {NgModule} from '@angular/core'; @@ -940,7 +946,9 @@ runInEachFileSystem(() => { import {Pipe} from '@angular/core'; @Pipe({name: 'foo'}) - export class BarPipe {} + export class BarPipe { + transform() {} + } `); env.write('bar_module.ts', ` import {NgModule} from '@angular/core'; diff --git a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts index f98377ea0f8e6..ea26ae2de416d 100644 --- a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts +++ b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts @@ -757,7 +757,9 @@ function allTests(os: string) { // ModuleA classes @Pipe({name: 'number'}) - class PipeA {} + class PipeA { + transform() {} + } @NgModule({ declarations: [PipeA], @@ -768,7 +770,9 @@ function allTests(os: string) { // ModuleB classes @Pipe({name: 'number'}) - class PipeB {} + class PipeB { + transform() {} + } @Component({ selector: 'app', @@ -800,7 +804,9 @@ function allTests(os: string) { // ModuleA classes @Pipe({name: 'number'}) - class PipeA {} + class PipeA { + transform() {} + } @NgModule({ declarations: [PipeA], @@ -811,7 +817,9 @@ function allTests(os: string) { // ModuleB classes @Pipe({name: 'number'}) - class PipeB {} + class PipeB { + transform() {} + } @NgModule({ declarations: [PipeB], @@ -1647,7 +1655,9 @@ function allTests(os: string) { import {Component, NgModule, Pipe} from '@angular/core'; @Pipe({name: 'test'}) - export class TestPipe {} + export class TestPipe { + transform() {} + } @Component({selector: 'test-cmp', template: '{{value | test}}'}) export class TestCmp { diff --git a/packages/language-service/ivy/language_service.ts b/packages/language-service/ivy/language_service.ts index ec6c18a09a613..320c98e7a54b8 100644 --- a/packages/language-service/ivy/language_service.ts +++ b/packages/language-service/ivy/language_service.ts @@ -26,7 +26,8 @@ import {CompilerFactory} from './compiler_factory'; import {CompletionBuilder, CompletionNodeContext} from './completions'; import {DefinitionBuilder} from './definitions'; import {QuickInfoBuilder} from './quick_info'; -import {ReferencesAndRenameBuilder} from './references'; +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 {findTightestNode, getClassDeclFromDecoratorProp, getPropertyAssignmentFromValue} from './ts_utils'; @@ -168,14 +169,15 @@ export class LanguageService { getReferencesAtPosition(fileName: string, position: number): ts.ReferenceEntry[]|undefined { return this.withCompilerAndPerfTracing(PerfPhase.LsReferencesAndRenames, (compiler) => { - return new ReferencesAndRenameBuilder(this.programDriver, this.tsLS, compiler) - .getReferencesAtPosition(fileName, position); + const results = new ReferencesBuilder(this.programDriver, this.tsLS, compiler) + .getReferencesAtPosition(fileName, position); + return results === undefined ? undefined : getUniqueLocations(results); }); } getRenameInfo(fileName: string, position: number): ts.RenameInfo { return this.withCompilerAndPerfTracing(PerfPhase.LsReferencesAndRenames, (compiler) => { - const renameInfo = new ReferencesAndRenameBuilder(this.programDriver, this.tsLS, compiler) + const renameInfo = new RenameBuilder(this.programDriver, this.tsLS, compiler) .getRenameInfo(absoluteFrom(fileName), position); if (!renameInfo.canRename) { return renameInfo; @@ -191,8 +193,9 @@ export class LanguageService { findRenameLocations(fileName: string, position: number): readonly ts.RenameLocation[]|undefined { return this.withCompilerAndPerfTracing(PerfPhase.LsReferencesAndRenames, (compiler) => { - return new ReferencesAndRenameBuilder(this.programDriver, this.tsLS, compiler) - .findRenameLocations(fileName, position); + const results = new RenameBuilder(this.programDriver, this.tsLS, compiler) + .findRenameLocations(fileName, position); + return results === null ? undefined : getUniqueLocations(results); }); } @@ -564,3 +567,11 @@ function findTightestNodeAtPosition(program: ts.Program, fileName: string, posit return findTightestNode(sourceFile, position); } + +function getUniqueLocations(locations: readonly T[]): T[] { + const uniqueLocations: Map = new Map(); + for (const location of locations) { + uniqueLocations.set(createLocationKey(location), location); + } + return Array.from(uniqueLocations.values()); +} \ No newline at end of file diff --git a/packages/language-service/ivy/references.ts b/packages/language-service/ivy/references.ts deleted file mode 100644 index 5a4ff0783fa98..0000000000000 --- a/packages/language-service/ivy/references.ts +++ /dev/null @@ -1,463 +0,0 @@ -/** - * @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 {AbsoluteSourceSpan, AST, BindingPipe, LiteralPrimitive, MethodCall, ParseSourceSpan, PropertyRead, PropertyWrite, SafeMethodCall, SafePropertyRead, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstNode, TmplAstReference, TmplAstTextAttribute, TmplAstVariable} from '@angular/compiler'; -import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core'; -import {absoluteFrom, absoluteFromSourceFile, AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system'; -import {PerfPhase} from '@angular/compiler-cli/src/ngtsc/perf'; -import {ProgramDriver} from '@angular/compiler-cli/src/ngtsc/program_driver'; -import {DirectiveSymbol, ShimLocation, SymbolKind, TemplateTypeChecker} from '@angular/compiler-cli/src/ngtsc/typecheck/api'; -import {ExpressionIdentifier, hasExpressionIdentifier} from '@angular/compiler-cli/src/ngtsc/typecheck/src/comments'; -import * as ts from 'typescript'; - -import {getTargetAtPosition, TargetNodeKind} from './template_target'; -import {findTightestNode} from './ts_utils'; -import {getDirectiveMatchesForAttribute, getDirectiveMatchesForElementTag, getTemplateInfoAtPosition, getTemplateLocationFromShimLocation, isWithin, TemplateInfo, toTextSpan} from './utils'; - -interface FilePosition { - fileName: string; - position: number; -} - -function toFilePosition(shimLocation: ShimLocation): FilePosition { - return {fileName: shimLocation.shimPath, position: shimLocation.positionInShimFile}; -} - -enum RequestKind { - Template, - TypeScript, -} - -interface TemplateRequest { - kind: RequestKind.Template; - requestNode: TmplAstNode|AST; - position: number; -} - -interface TypeScriptRequest { - kind: RequestKind.TypeScript; - requestNode: ts.Node; -} - -type RequestOrigin = TemplateRequest|TypeScriptRequest; - -interface TemplateLocationDetails { - /** - * A target node in a template. - */ - templateTarget: TmplAstNode|AST; - - /** - * TypeScript locations which the template node maps to. A given template node might map to - * several TS nodes. For example, a template node for an attribute might resolve to several - * directives or a directive and one of its inputs. - */ - typescriptLocations: FilePosition[]; -} - -export class ReferencesAndRenameBuilder { - private readonly ttc = this.compiler.getTemplateTypeChecker(); - - constructor( - private readonly driver: ProgramDriver, private readonly tsLS: ts.LanguageService, - private readonly compiler: NgCompiler) {} - - getRenameInfo(filePath: string, position: number): - Omit|ts.RenameInfoFailure { - return this.compiler.perfRecorder.inPhase(PerfPhase.LsReferencesAndRenames, () => { - const templateInfo = getTemplateInfoAtPosition(filePath, position, this.compiler); - // We could not get a template at position so we assume the request came from outside the - // template. - if (templateInfo === undefined) { - return this.tsLS.getRenameInfo(filePath, position); - } - - const allTargetDetails = this.getTargetDetailsAtTemplatePosition(templateInfo, position); - if (allTargetDetails === null) { - return { - canRename: false, - localizedErrorMessage: 'Could not find template node at position.', - }; - } - const {templateTarget} = allTargetDetails[0]; - const templateTextAndSpan = getRenameTextAndSpanAtPosition(templateTarget, position); - if (templateTextAndSpan === null) { - return {canRename: false, localizedErrorMessage: 'Could not determine template node text.'}; - } - const {text, span} = templateTextAndSpan; - return { - canRename: true, - displayName: text, - fullDisplayName: text, - triggerSpan: span, - }; - }); - } - - findRenameLocations(filePath: string, position: number): readonly ts.RenameLocation[]|undefined { - this.ttc.generateAllTypeCheckBlocks(); - return this.compiler.perfRecorder.inPhase(PerfPhase.LsReferencesAndRenames, () => { - const templateInfo = getTemplateInfoAtPosition(filePath, position, this.compiler); - // We could not get a template at position so we assume the request came from outside the - // template. - if (templateInfo === undefined) { - const requestNode = this.getTsNodeAtPosition(filePath, position); - if (requestNode === null) { - return undefined; - } - const requestOrigin: TypeScriptRequest = {kind: RequestKind.TypeScript, requestNode}; - return this.findRenameLocationsAtTypescriptPosition(filePath, position, requestOrigin); - } - - return this.findRenameLocationsAtTemplatePosition(templateInfo, position); - }); - } - - private findRenameLocationsAtTemplatePosition(templateInfo: TemplateInfo, position: number): - readonly ts.RenameLocation[]|undefined { - const allTargetDetails = this.getTargetDetailsAtTemplatePosition(templateInfo, position); - if (allTargetDetails === null) { - return undefined; - } - - const allRenameLocations: ts.RenameLocation[] = []; - for (const targetDetails of allTargetDetails) { - const requestOrigin: TemplateRequest = { - kind: RequestKind.Template, - requestNode: targetDetails.templateTarget, - position, - }; - - for (const location of targetDetails.typescriptLocations) { - const locations = this.findRenameLocationsAtTypescriptPosition( - location.fileName, location.position, requestOrigin); - // If we couldn't find rename locations for _any_ result, we should not allow renaming to - // proceed instead of having a partially complete rename. - if (locations === undefined) { - return undefined; - } - allRenameLocations.push(...locations); - } - } - return allRenameLocations.length > 0 ? allRenameLocations : undefined; - } - - private getTsNodeAtPosition(filePath: string, position: number): ts.Node|null { - const sf = this.driver.getProgram().getSourceFile(filePath); - if (!sf) { - return null; - } - return findTightestNode(sf, position) ?? null; - } - - findRenameLocationsAtTypescriptPosition( - filePath: string, position: number, - requestOrigin: RequestOrigin): readonly ts.RenameLocation[]|undefined { - return this.compiler.perfRecorder.inPhase(PerfPhase.LsReferencesAndRenames, () => { - let originalNodeText: string; - if (requestOrigin.kind === RequestKind.TypeScript) { - originalNodeText = requestOrigin.requestNode.getText(); - } else { - const templateNodeText = - getRenameTextAndSpanAtPosition(requestOrigin.requestNode, requestOrigin.position); - if (templateNodeText === null) { - return undefined; - } - originalNodeText = templateNodeText.text; - } - - const locations = this.tsLS.findRenameLocations( - filePath, position, /*findInStrings*/ false, /*findInComments*/ false); - if (locations === undefined) { - return undefined; - } - - const entries: Map = new Map(); - for (const location of locations) { - // TODO(atscott): Determine if a file is a shim file in a more robust way and make the API - // available in an appropriate location. - if (this.ttc.isTrackedTypeCheckFile(absoluteFrom(location.fileName))) { - const entry = this.convertToTemplateDocumentSpan(location, this.ttc, originalNodeText); - // There is no template node whose text matches the original rename request. Bail on - // renaming completely rather than providing incomplete results. - if (entry === null) { - return undefined; - } - entries.set(createLocationKey(entry), entry); - } else { - // Ensure we only allow renaming a TS result with matching text - const refNode = this.getTsNodeAtPosition(location.fileName, location.textSpan.start); - if (refNode === null || refNode.getText() !== originalNodeText) { - return undefined; - } - entries.set(createLocationKey(location), location); - } - } - return Array.from(entries.values()); - }); - } - - getReferencesAtPosition(filePath: string, position: number): ts.ReferenceEntry[]|undefined { - this.ttc.generateAllTypeCheckBlocks(); - - return this.compiler.perfRecorder.inPhase(PerfPhase.LsReferencesAndRenames, () => { - const templateInfo = getTemplateInfoAtPosition(filePath, position, this.compiler); - if (templateInfo === undefined) { - return this.getReferencesAtTypescriptPosition(filePath, position); - } - return this.getReferencesAtTemplatePosition(templateInfo, position); - }); - } - - private getReferencesAtTemplatePosition(templateInfo: TemplateInfo, position: number): - ts.ReferenceEntry[]|undefined { - const allTargetDetails = this.getTargetDetailsAtTemplatePosition(templateInfo, position); - if (allTargetDetails === null) { - return undefined; - } - const allReferences: ts.ReferenceEntry[] = []; - for (const targetDetails of allTargetDetails) { - for (const location of targetDetails.typescriptLocations) { - const refs = this.getReferencesAtTypescriptPosition(location.fileName, location.position); - if (refs !== undefined) { - allReferences.push(...refs); - } - } - } - return allReferences.length > 0 ? allReferences : undefined; - } - - private getTargetDetailsAtTemplatePosition({template, component}: TemplateInfo, position: number): - TemplateLocationDetails[]|null { - // Find the AST node in the template at the position. - const positionDetails = getTargetAtPosition(template, position); - if (positionDetails === null) { - return null; - } - - const nodes = positionDetails.context.kind === TargetNodeKind.TwoWayBindingContext ? - positionDetails.context.nodes : - [positionDetails.context.node]; - - const details: TemplateLocationDetails[] = []; - - for (const node of nodes) { - // Get the information about the TCB at the template position. - const symbol = this.ttc.getSymbolOfNode(node, component); - if (symbol === null) { - continue; - } - - const templateTarget = node; - switch (symbol.kind) { - case SymbolKind.Directive: - case SymbolKind.Template: - // References to elements, templates, and directives will be through template references - // (#ref). They shouldn't be used directly for a Language Service reference request. - break; - case SymbolKind.Element: { - const matches = getDirectiveMatchesForElementTag(symbol.templateNode, symbol.directives); - details.push( - {typescriptLocations: this.getPositionsForDirectives(matches), templateTarget}); - break; - } - case SymbolKind.DomBinding: { - // Dom bindings aren't currently type-checked (see `checkTypeOfDomBindings`) so they don't - // have a shim location. This means we can't match dom bindings to their lib.dom - // reference, but we can still see if they match to a directive. - if (!(node instanceof TmplAstTextAttribute) && !(node instanceof TmplAstBoundAttribute)) { - return null; - } - const directives = getDirectiveMatchesForAttribute( - node.name, symbol.host.templateNode, symbol.host.directives); - details.push({ - typescriptLocations: this.getPositionsForDirectives(directives), - templateTarget, - }); - break; - } - case SymbolKind.Reference: { - details.push({ - typescriptLocations: [toFilePosition(symbol.referenceVarLocation)], - templateTarget, - }); - break; - } - case SymbolKind.Variable: { - if ((templateTarget instanceof TmplAstVariable)) { - if (templateTarget.valueSpan !== undefined && - isWithin(position, templateTarget.valueSpan)) { - // In the valueSpan of the variable, we want to get the reference of the initializer. - details.push({ - typescriptLocations: [toFilePosition(symbol.initializerLocation)], - templateTarget, - }); - } else if (isWithin(position, templateTarget.keySpan)) { - // In the keySpan of the variable, we want to get the reference of the local variable. - details.push({ - typescriptLocations: [toFilePosition(symbol.localVarLocation)], - templateTarget, - }); - } - } else { - // If the templateNode is not the `TmplAstVariable`, it must be a usage of the - // variable somewhere in the template. - details.push({ - typescriptLocations: [toFilePosition(symbol.localVarLocation)], - templateTarget, - }); - } - break; - } - case SymbolKind.Input: - case SymbolKind.Output: { - details.push({ - typescriptLocations: - symbol.bindings.map(binding => toFilePosition(binding.shimLocation)), - templateTarget, - }); - break; - } - case SymbolKind.Pipe: - case SymbolKind.Expression: { - details.push( - {typescriptLocations: [toFilePosition(symbol.shimLocation)], templateTarget}); - break; - } - } - } - - return details.length > 0 ? details : null; - } - - private getPositionsForDirectives(directives: Set): FilePosition[] { - const allDirectives: FilePosition[] = []; - for (const dir of directives.values()) { - const dirClass = dir.tsSymbol.valueDeclaration; - if (dirClass === undefined || !ts.isClassDeclaration(dirClass) || - dirClass.name === undefined) { - continue; - } - - const {fileName} = dirClass.getSourceFile(); - const position = dirClass.name.getStart(); - allDirectives.push({fileName, position}); - } - - return allDirectives; - } - - private getReferencesAtTypescriptPosition(fileName: string, position: number): - ts.ReferenceEntry[]|undefined { - const refs = this.tsLS.getReferencesAtPosition(fileName, position); - if (refs === undefined) { - return undefined; - } - - const entries: Map = new Map(); - for (const ref of refs) { - if (this.ttc.isTrackedTypeCheckFile(absoluteFrom(ref.fileName))) { - const entry = this.convertToTemplateDocumentSpan(ref, this.ttc); - if (entry !== null) { - entries.set(createLocationKey(entry), entry); - } - } else { - entries.set(createLocationKey(ref), ref); - } - } - return Array.from(entries.values()); - } - - private convertToTemplateDocumentSpan( - shimDocumentSpan: T, templateTypeChecker: TemplateTypeChecker, requiredNodeText?: string): T - |null { - const sf = this.driver.getProgram().getSourceFile(shimDocumentSpan.fileName); - if (sf === undefined) { - return null; - } - const tcbNode = findTightestNode(sf, shimDocumentSpan.textSpan.start); - if (tcbNode === undefined || - hasExpressionIdentifier(sf, tcbNode, ExpressionIdentifier.EVENT_PARAMETER)) { - // If the reference result is the $event parameter in the subscribe/addEventListener - // function in the TCB, we want to filter this result out of the references. We really only - // want to return references to the parameter in the template itself. - return null; - } - // TODO(atscott): Determine how to consistently resolve paths. i.e. with the project - // serverHost or LSParseConfigHost in the adapter. We should have a better defined way to - // normalize paths. - const mapping = getTemplateLocationFromShimLocation( - templateTypeChecker, absoluteFrom(shimDocumentSpan.fileName), - shimDocumentSpan.textSpan.start); - if (mapping === null) { - return null; - } - - const {span, templateUrl} = mapping; - if (requiredNodeText !== undefined && span.toString() !== requiredNodeText) { - return null; - } - - return { - ...shimDocumentSpan, - fileName: templateUrl, - textSpan: toTextSpan(span), - // Specifically clear other text span values because we do not have enough knowledge to - // convert these to spans in the template. - contextSpan: undefined, - originalContextSpan: undefined, - originalTextSpan: undefined, - }; - } -} - -function getRenameTextAndSpanAtPosition( - node: TmplAstNode|AST, position: number): {text: string, span: ts.TextSpan}|null { - if (node instanceof TmplAstBoundAttribute || node instanceof TmplAstTextAttribute || - node instanceof TmplAstBoundEvent) { - if (node.keySpan === undefined) { - return null; - } - return {text: node.name, span: toTextSpan(node.keySpan)}; - } else if (node instanceof TmplAstVariable || node instanceof TmplAstReference) { - if (isWithin(position, node.keySpan)) { - return {text: node.keySpan.toString(), span: toTextSpan(node.keySpan)}; - } else if (node.valueSpan && isWithin(position, node.valueSpan)) { - return {text: node.valueSpan.toString(), span: toTextSpan(node.valueSpan)}; - } - } - - if (node instanceof BindingPipe) { - // TODO(atscott): Add support for renaming pipes - return null; - } - if (node instanceof PropertyRead || node instanceof MethodCall || node instanceof PropertyWrite || - node instanceof SafePropertyRead || node instanceof SafeMethodCall) { - return {text: node.name, span: toTextSpan(node.nameSpan)}; - } else if (node instanceof LiteralPrimitive) { - const span = toTextSpan(node.sourceSpan); - const text = node.value; - if (typeof text === 'string') { - // The span of a string literal includes the quotes but they should be removed for renaming. - span.start += 1; - span.length -= 2; - } - return {text, span}; - } - - return null; -} - - -/** - * Creates a "key" for a rename/reference location by concatenating file name, span start, and span - * length. This allows us to de-duplicate template results when an item may appear several times - * in the TCB but map back to the same template location. - */ -function createLocationKey(ds: ts.DocumentSpan) { - return ds.fileName + ds.textSpan.start + ds.textSpan.length; -} \ No newline at end of file diff --git a/packages/language-service/ivy/references_and_rename.ts b/packages/language-service/ivy/references_and_rename.ts new file mode 100644 index 0000000000000..d4e16baa2c2f7 --- /dev/null +++ b/packages/language-service/ivy/references_and_rename.ts @@ -0,0 +1,394 @@ +/** + * @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 {AST, TmplAstNode} from '@angular/compiler'; +import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core'; +import {absoluteFrom} from '@angular/compiler-cli/src/ngtsc/file_system'; +import {MetaType, PipeMeta} from '@angular/compiler-cli/src/ngtsc/metadata'; +import {PerfPhase} from '@angular/compiler-cli/src/ngtsc/perf'; +import {ProgramDriver} from '@angular/compiler-cli/src/ngtsc/program_driver'; +import {SymbolKind} from '@angular/compiler-cli/src/ngtsc/typecheck/api'; +import * as ts from 'typescript'; + +import {convertToTemplateDocumentSpan, createLocationKey, FilePosition, getParentClassMeta, getRenameTextAndSpanAtPosition, getTargetDetailsAtTemplatePosition, TemplateLocationDetails} from './references_and_rename_utils'; +import {collectMemberMethods, findTightestNode} from './ts_utils'; +import {getTemplateInfoAtPosition, TemplateInfo} from './utils'; + +export class ReferencesBuilder { + private readonly ttc = this.compiler.getTemplateTypeChecker(); + + constructor( + private readonly driver: ProgramDriver, private readonly tsLS: ts.LanguageService, + private readonly compiler: NgCompiler) {} + + getReferencesAtPosition(filePath: string, position: number): ts.ReferenceEntry[]|undefined { + this.ttc.generateAllTypeCheckBlocks(); + const templateInfo = getTemplateInfoAtPosition(filePath, position, this.compiler); + if (templateInfo === undefined) { + return this.getReferencesAtTypescriptPosition(filePath, position); + } + return this.getReferencesAtTemplatePosition(templateInfo, position); + } + + private getReferencesAtTemplatePosition(templateInfo: TemplateInfo, position: number): + ts.ReferenceEntry[]|undefined { + const allTargetDetails = getTargetDetailsAtTemplatePosition(templateInfo, position, this.ttc); + if (allTargetDetails === null) { + return undefined; + } + const allReferences: ts.ReferenceEntry[] = []; + for (const targetDetails of allTargetDetails) { + for (const location of targetDetails.typescriptLocations) { + const refs = this.getReferencesAtTypescriptPosition(location.fileName, location.position); + if (refs !== undefined) { + allReferences.push(...refs); + } + } + } + return allReferences.length > 0 ? allReferences : undefined; + } + + private getReferencesAtTypescriptPosition(fileName: string, position: number): + ts.ReferenceEntry[]|undefined { + const refs = this.tsLS.getReferencesAtPosition(fileName, position); + if (refs === undefined) { + return undefined; + } + + const entries: ts.ReferenceEntry[] = []; + for (const ref of refs) { + if (this.ttc.isTrackedTypeCheckFile(absoluteFrom(ref.fileName))) { + const entry = convertToTemplateDocumentSpan(ref, this.ttc, this.driver.getProgram()); + if (entry !== null) { + entries.push(entry); + } + } else { + entries.push(ref); + } + } + return entries; + } +} + +enum RequestKind { + DirectFromTemplate, + DirectFromTypeScript, + PipeName, + Selector, +} + +/** The context needed to perform a rename of a pipe name. */ +interface PipeRenameContext { + type: RequestKind.PipeName; + + /** The string literal for the pipe name that appears in the @Pipe meta */ + pipeNameExpr: ts.StringLiteral; + + /** + * The location to use for querying the native TS LS for rename positions. This will be the + * pipe's transform method. + */ + renamePosition: FilePosition; +} + +/** The context needed to perform a rename of a directive/component selector. */ +interface SelectorRenameContext { + type: RequestKind.Selector; + + /** The string literal that appears in the directive/component metadata. */ + selectorExpr: ts.StringLiteral; + + /** + * The location to use for querying the native TS LS for rename positions. This will be the + * component/directive class itself. Doing so will allow us to find the location of the + * directive/component instantiations, which map to template elements. + */ + renamePosition: FilePosition; +} + +interface DirectFromTypescriptRenameContext { + type: RequestKind.DirectFromTypeScript; + + /** The node that is being renamed. */ + requestNode: ts.Node; +} + +interface DirectFromTemplateRenameContext { + type: RequestKind.DirectFromTemplate; + + /** The position in the TCB file to use as the request to the native TSLS for renaming. */ + renamePosition: FilePosition; + + /** The position in the template the request originated from. */ + templatePosition: number; + + /** The target node in the template AST that corresponds to the template position. */ + requestNode: AST|TmplAstNode; +} + +type IndirectRenameContext = PipeRenameContext|SelectorRenameContext; +type RenameRequest = + IndirectRenameContext|DirectFromTemplateRenameContext|DirectFromTypescriptRenameContext; + +function isDirectRenameContext(context: RenameRequest): context is DirectFromTemplateRenameContext| + DirectFromTypescriptRenameContext { + return context.type === RequestKind.DirectFromTemplate || + context.type === RequestKind.DirectFromTypeScript; +} + +export class RenameBuilder { + private readonly ttc = this.compiler.getTemplateTypeChecker(); + + constructor( + private readonly driver: ProgramDriver, private readonly tsLS: ts.LanguageService, + private readonly compiler: NgCompiler) {} + + getRenameInfo(filePath: string, position: number): + Omit|ts.RenameInfoFailure { + return this.compiler.perfRecorder.inPhase(PerfPhase.LsReferencesAndRenames, () => { + const templateInfo = getTemplateInfoAtPosition(filePath, position, this.compiler); + // We could not get a template at position so we assume the request came from outside the + // template. + if (templateInfo === undefined) { + return this.tsLS.getRenameInfo(filePath, position); + } + + const allTargetDetails = getTargetDetailsAtTemplatePosition(templateInfo, position, this.ttc); + if (allTargetDetails === null) { + return { + canRename: false, + localizedErrorMessage: 'Could not find template node at position.' + }; + } + const {templateTarget} = allTargetDetails[0]; + const templateTextAndSpan = getRenameTextAndSpanAtPosition( + templateTarget, + position, + ); + if (templateTextAndSpan === null) { + return {canRename: false, localizedErrorMessage: 'Could not determine template node text.'}; + } + const {text, span} = templateTextAndSpan; + return { + canRename: true, + displayName: text, + fullDisplayName: text, + triggerSpan: span, + }; + }); + } + + findRenameLocations(filePath: string, position: number): readonly ts.RenameLocation[]|null { + this.ttc.generateAllTypeCheckBlocks(); + return this.compiler.perfRecorder.inPhase(PerfPhase.LsReferencesAndRenames, () => { + const templateInfo = getTemplateInfoAtPosition(filePath, position, this.compiler); + // We could not get a template at position so we assume the request came from outside the + // template. + if (templateInfo === undefined) { + const renameRequest = this.buildRenameRequestAtTypescriptPosition(filePath, position); + if (renameRequest === null) { + return null; + } + return this.findRenameLocationsAtTypescriptPosition(renameRequest); + } + return this.findRenameLocationsAtTemplatePosition(templateInfo, position); + }); + } + + private findRenameLocationsAtTemplatePosition(templateInfo: TemplateInfo, position: number): + readonly ts.RenameLocation[]|null { + const allTargetDetails = getTargetDetailsAtTemplatePosition(templateInfo, position, this.ttc); + if (allTargetDetails === null) { + return null; + } + const renameRequests = this.buildRenameRequestsFromTemplateDetails(allTargetDetails, position); + if (renameRequests === null) { + return null; + } + + const allRenameLocations: ts.RenameLocation[] = []; + for (const renameRequest of renameRequests) { + const locations = this.findRenameLocationsAtTypescriptPosition(renameRequest); + // If we couldn't find rename locations for _any_ result, we should not allow renaming to + // proceed instead of having a partially complete rename. + if (locations === null) { + return null; + } + allRenameLocations.push(...locations); + } + return allRenameLocations.length > 0 ? allRenameLocations : null; + } + + findRenameLocationsAtTypescriptPosition(renameRequest: RenameRequest): + readonly ts.RenameLocation[]|null { + return this.compiler.perfRecorder.inPhase(PerfPhase.LsReferencesAndRenames, () => { + const renameInfo = getExpectedRenameTextAndInitalRenameEntries(renameRequest); + if (renameInfo === null) { + return null; + } + const {entries, expectedRenameText} = renameInfo; + const {fileName, position} = getRenameRequestPosition(renameRequest); + const findInStrings = false; + const findInComments = false; + const locations = + this.tsLS.findRenameLocations(fileName, position, findInStrings, findInComments); + if (locations === undefined) { + return null; + } + + for (const location of locations) { + if (this.ttc.isTrackedTypeCheckFile(absoluteFrom(location.fileName))) { + const entry = convertToTemplateDocumentSpan( + location, this.ttc, this.driver.getProgram(), expectedRenameText); + // There is no template node whose text matches the original rename request. Bail on + // renaming completely rather than providing incomplete results. + if (entry === null) { + return null; + } + entries.push(entry); + } else { + if (!isDirectRenameContext(renameRequest)) { + // Discard any non-template results for non-direct renames. We should only rename + // template results + the name/selector/alias `ts.Expression`. The other results + // will be the the `ts.Identifier` of the transform method (pipe rename) or the + // directive class (selector rename). + continue; + } + // Ensure we only allow renaming a TS result with matching text + const refNode = this.getTsNodeAtPosition(location.fileName, location.textSpan.start); + if (refNode === null || refNode.getText() !== expectedRenameText) { + return null; + } + entries.push(location); + } + } + return entries; + }); + } + + private getTsNodeAtPosition(filePath: string, position: number): ts.Node|null { + const sf = this.driver.getProgram().getSourceFile(filePath); + if (!sf) { + return null; + } + return findTightestNode(sf, position) ?? null; + } + + private buildRenameRequestsFromTemplateDetails( + allTargetDetails: TemplateLocationDetails[], templatePosition: number): RenameRequest[]|null { + const renameRequests: RenameRequest[] = []; + for (const targetDetails of allTargetDetails) { + for (const location of targetDetails.typescriptLocations) { + if (targetDetails.symbol.kind === SymbolKind.Pipe) { + const meta = + this.compiler.getMeta(targetDetails.symbol.classSymbol.tsSymbol.valueDeclaration); + if (meta === null || meta.type !== MetaType.Pipe) { + return null; + } + const renameRequest = this.buildPipeRenameRequest(meta); + if (renameRequest === null) { + return null; + } + renameRequests.push(renameRequest); + } else { + const renameRequest: RenameRequest = { + type: RequestKind.DirectFromTemplate, + templatePosition, + requestNode: targetDetails.templateTarget, + renamePosition: location + }; + renameRequests.push(renameRequest); + } + } + } + return renameRequests; + } + + private buildRenameRequestAtTypescriptPosition(filePath: string, position: number): RenameRequest + |null { + const requestNode = this.getTsNodeAtPosition(filePath, position); + if (requestNode === null) { + return null; + } + const meta = getParentClassMeta(requestNode, this.compiler); + if (meta !== null && meta.type === MetaType.Pipe && meta.nameExpr === requestNode) { + return this.buildPipeRenameRequest(meta); + } else { + return {type: RequestKind.DirectFromTypeScript, requestNode}; + } + } + + private buildPipeRenameRequest(meta: PipeMeta): PipeRenameContext|null { + if (!ts.isClassDeclaration(meta.ref.node) || meta.nameExpr === null || + !ts.isStringLiteral(meta.nameExpr)) { + return null; + } + const typeChecker = this.driver.getProgram().getTypeChecker(); + const memberMethods = collectMemberMethods(meta.ref.node, typeChecker) ?? []; + const pipeTransformNode: ts.MethodDeclaration|undefined = + memberMethods.find(m => m.name.getText() === 'transform'); + if (pipeTransformNode === undefined) { + return null; + } + return { + type: RequestKind.PipeName, + pipeNameExpr: meta.nameExpr, + renamePosition: { + fileName: pipeTransformNode.getSourceFile().fileName, + position: pipeTransformNode.getStart(), + } + }; + } +} + +/** + * From the provided `RenameRequest`, determines what text we should expect all produced + * `ts.RenameLocation`s to have and creates an initial entry for indirect renames (one which is + * required for the rename operation, but cannot be found by the native TS LS). + */ +function getExpectedRenameTextAndInitalRenameEntries(renameRequest: RenameRequest): + {expectedRenameText: string, entries: ts.RenameLocation[]}|null { + let expectedRenameText: string; + const entries: ts.RenameLocation[] = []; + if (renameRequest.type === RequestKind.DirectFromTypeScript) { + expectedRenameText = renameRequest.requestNode.getText(); + } else if (renameRequest.type === RequestKind.DirectFromTemplate) { + const templateNodeText = + getRenameTextAndSpanAtPosition(renameRequest.requestNode, renameRequest.templatePosition); + if (templateNodeText === null) { + return null; + } + expectedRenameText = templateNodeText.text; + } else if (renameRequest.type === RequestKind.PipeName) { + const {pipeNameExpr} = renameRequest; + expectedRenameText = pipeNameExpr.text; + const entry: ts.RenameLocation = { + fileName: renameRequest.pipeNameExpr.getSourceFile().fileName, + textSpan: {start: pipeNameExpr.getStart() + 1, length: pipeNameExpr.getText().length - 2}, + }; + entries.push(entry); + } else { + // TODO(atscott): Implement other types of special renames + return null; + } + + return {entries, expectedRenameText}; +} + +/** + * Given a `RenameRequest`, determines the `FilePosition` to use asking the native TS LS for rename + * locations. + */ +function getRenameRequestPosition(renameRequest: RenameRequest): FilePosition { + const fileName = renameRequest.type === RequestKind.DirectFromTypeScript ? + renameRequest.requestNode.getSourceFile().fileName : + renameRequest.renamePosition.fileName; + const position = renameRequest.type === RequestKind.DirectFromTypeScript ? + renameRequest.requestNode.getStart() : + renameRequest.renamePosition.position; + return {fileName, position}; +} \ No newline at end of file diff --git a/packages/language-service/ivy/references_and_rename_utils.ts b/packages/language-service/ivy/references_and_rename_utils.ts new file mode 100644 index 0000000000000..274f89837e88f --- /dev/null +++ b/packages/language-service/ivy/references_and_rename_utils.ts @@ -0,0 +1,297 @@ +/** + * @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 {AST, BindingPipe, LiteralPrimitive, MethodCall, PropertyRead, PropertyWrite, SafeMethodCall, SafePropertyRead, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstNode, TmplAstReference, TmplAstTextAttribute, TmplAstVariable} from '@angular/compiler'; +import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core'; +import {absoluteFrom} from '@angular/compiler-cli/src/ngtsc/file_system'; +import {DirectiveMeta, PipeMeta} from '@angular/compiler-cli/src/ngtsc/metadata'; +import {DirectiveSymbol, ShimLocation, Symbol, SymbolKind, TemplateTypeChecker} from '@angular/compiler-cli/src/ngtsc/typecheck/api'; +import {ExpressionIdentifier, hasExpressionIdentifier} from '@angular/compiler-cli/src/ngtsc/typecheck/src/comments'; +import * as ts from 'typescript'; + +import {getTargetAtPosition, TargetNodeKind} from './template_target'; +import {findTightestNode, getParentClassDeclaration} from './ts_utils'; +import {getDirectiveMatchesForAttribute, getDirectiveMatchesForElementTag, getTemplateLocationFromShimLocation, isWithin, TemplateInfo, toTextSpan} from './utils'; + +/** Represents a location in a file. */ +export interface FilePosition { + fileName: string; + position: number; +} + +/** + * Converts a `ShimLocation` to a more genericly named `FilePosition`. + */ +function toFilePosition(shimLocation: ShimLocation): FilePosition { + return {fileName: shimLocation.shimPath, position: shimLocation.positionInShimFile}; +} +export interface TemplateLocationDetails { + /** + * A target node in a template. + */ + templateTarget: TmplAstNode|AST; + + /** + * TypeScript locations which the template node maps to. A given template node might map to + * several TS nodes. For example, a template node for an attribute might resolve to several + * directives or a directive and one of its inputs. + */ + typescriptLocations: FilePosition[]; + + /** + * The resolved Symbol for the template target. + */ + symbol: Symbol; +} + + +/** + * Takes a position in a template and finds equivalent targets in TS files as well as details about + * the targeted template node. + */ +export function getTargetDetailsAtTemplatePosition( + {template, component}: TemplateInfo, position: number, + templateTypeChecker: TemplateTypeChecker): TemplateLocationDetails[]|null { + // Find the AST node in the template at the position. + const positionDetails = getTargetAtPosition(template, position); + if (positionDetails === null) { + return null; + } + + const nodes = positionDetails.context.kind === TargetNodeKind.TwoWayBindingContext ? + positionDetails.context.nodes : + [positionDetails.context.node]; + + const details: TemplateLocationDetails[] = []; + + for (const node of nodes) { + // Get the information about the TCB at the template position. + const symbol = templateTypeChecker.getSymbolOfNode(node, component); + if (symbol === null) { + continue; + } + + const templateTarget = node; + switch (symbol.kind) { + case SymbolKind.Directive: + case SymbolKind.Template: + // References to elements, templates, and directives will be through template references + // (#ref). They shouldn't be used directly for a Language Service reference request. + break; + case SymbolKind.Element: { + const matches = getDirectiveMatchesForElementTag(symbol.templateNode, symbol.directives); + details.push({ + typescriptLocations: getPositionsForDirectives(matches), + templateTarget, + symbol, + }); + break; + } + case SymbolKind.DomBinding: { + // Dom bindings aren't currently type-checked (see `checkTypeOfDomBindings`) so they don't + // have a shim location. This means we can't match dom bindings to their lib.dom + // reference, but we can still see if they match to a directive. + if (!(node instanceof TmplAstTextAttribute) && !(node instanceof TmplAstBoundAttribute)) { + return null; + } + const directives = getDirectiveMatchesForAttribute( + node.name, symbol.host.templateNode, symbol.host.directives); + details.push({ + typescriptLocations: getPositionsForDirectives(directives), + templateTarget, + symbol, + }); + break; + } + case SymbolKind.Reference: { + details.push({ + typescriptLocations: [toFilePosition(symbol.referenceVarLocation)], + templateTarget, + symbol, + }); + break; + } + case SymbolKind.Variable: { + if ((templateTarget instanceof TmplAstVariable)) { + if (templateTarget.valueSpan !== undefined && + isWithin(position, templateTarget.valueSpan)) { + // In the valueSpan of the variable, we want to get the reference of the initializer. + details.push({ + typescriptLocations: [toFilePosition(symbol.initializerLocation)], + templateTarget, + symbol, + }); + } else if (isWithin(position, templateTarget.keySpan)) { + // In the keySpan of the variable, we want to get the reference of the local variable. + details.push({ + typescriptLocations: [toFilePosition(symbol.localVarLocation)], + templateTarget, + symbol, + }); + } + } else { + // If the templateNode is not the `TmplAstVariable`, it must be a usage of the + // variable somewhere in the template. + details.push({ + typescriptLocations: [toFilePosition(symbol.localVarLocation)], + templateTarget, + symbol, + }); + } + break; + } + case SymbolKind.Input: + case SymbolKind.Output: { + details.push({ + typescriptLocations: symbol.bindings.map(binding => toFilePosition(binding.shimLocation)), + templateTarget, + symbol, + }); + break; + } + case SymbolKind.Pipe: + case SymbolKind.Expression: { + details.push({ + typescriptLocations: [toFilePosition(symbol.shimLocation)], + templateTarget, + symbol, + }); + break; + } + } + } + + return details.length > 0 ? details : null; +} + +/** + * Given a set of `DirectiveSymbol`s, finds the equivalent `FilePosition` of the class declaration. + */ +function getPositionsForDirectives(directives: Set): FilePosition[] { + const allDirectives: FilePosition[] = []; + for (const dir of directives.values()) { + const dirClass = dir.tsSymbol.valueDeclaration; + if (dirClass === undefined || !ts.isClassDeclaration(dirClass) || dirClass.name === undefined) { + continue; + } + + const {fileName} = dirClass.getSourceFile(); + const position = dirClass.name.getStart(); + allDirectives.push({fileName, position}); + } + + return allDirectives; +} + +/** + * Creates a "key" for a rename/reference location by concatenating file name, span start, and span + * length. This allows us to de-duplicate template results when an item may appear several times + * in the TCB but map back to the same template location. + */ +export function createLocationKey(ds: ts.DocumentSpan) { + return ds.fileName + ds.textSpan.start + ds.textSpan.length; +} + +/** + * Converts a given `ts.DocumentSpan` in a shim file to its equivalent `ts.DocumentSpan` in the + * template. + * + * You can optionally provide a `requiredNodeText` that ensures the equivalent template node's text + * matches. If it does not, this function will return `null`. + */ +export function convertToTemplateDocumentSpan( + shimDocumentSpan: T, templateTypeChecker: TemplateTypeChecker, program: ts.Program, + requiredNodeText?: string): T|null { + const sf = program.getSourceFile(shimDocumentSpan.fileName); + if (sf === undefined) { + return null; + } + const tcbNode = findTightestNode(sf, shimDocumentSpan.textSpan.start); + if (tcbNode === undefined || + hasExpressionIdentifier(sf, tcbNode, ExpressionIdentifier.EVENT_PARAMETER)) { + // If the reference result is the $event parameter in the subscribe/addEventListener + // function in the TCB, we want to filter this result out of the references. We really only + // want to return references to the parameter in the template itself. + return null; + } + // TODO(atscott): Determine how to consistently resolve paths. i.e. with the project + // serverHost or LSParseConfigHost in the adapter. We should have a better defined way to + // normalize paths. + const mapping = getTemplateLocationFromShimLocation( + templateTypeChecker, absoluteFrom(shimDocumentSpan.fileName), + shimDocumentSpan.textSpan.start); + if (mapping === null) { + return null; + } + + const {span, templateUrl} = mapping; + if (requiredNodeText !== undefined && span.toString() !== requiredNodeText) { + return null; + } + + return { + ...shimDocumentSpan, + fileName: templateUrl, + textSpan: toTextSpan(span), + // Specifically clear other text span values because we do not have enough knowledge to + // convert these to spans in the template. + contextSpan: undefined, + originalContextSpan: undefined, + originalTextSpan: undefined, + }; +} + +/** + * Finds the text and `ts.TextSpan` for the node at a position in a template. + */ +export function getRenameTextAndSpanAtPosition( + node: TmplAstNode|AST, position: number): {text: string, span: ts.TextSpan}|null { + if (node instanceof TmplAstBoundAttribute || node instanceof TmplAstTextAttribute || + node instanceof TmplAstBoundEvent) { + if (node.keySpan === undefined) { + return null; + } + return {text: node.name, span: toTextSpan(node.keySpan)}; + } else if (node instanceof TmplAstVariable || node instanceof TmplAstReference) { + if (isWithin(position, node.keySpan)) { + return {text: node.keySpan.toString(), span: toTextSpan(node.keySpan)}; + } else if (node.valueSpan && isWithin(position, node.valueSpan)) { + return {text: node.valueSpan.toString(), span: toTextSpan(node.valueSpan)}; + } + } + + if (node instanceof PropertyRead || node instanceof MethodCall || node instanceof PropertyWrite || + node instanceof SafePropertyRead || node instanceof SafeMethodCall || + node instanceof BindingPipe) { + return {text: node.name, span: toTextSpan(node.nameSpan)}; + } else if (node instanceof LiteralPrimitive) { + const span = toTextSpan(node.sourceSpan); + const text = node.value; + if (typeof text === 'string') { + // The span of a string literal includes the quotes but they should be removed for renaming. + span.start += 1; + span.length -= 2; + } + return {text, span}; + } + + return null; +} + +/** + * Retrives the `PipeMeta` or `DirectiveMeta` of the given `ts.Node`'s parent class. + * + * Returns `null` if the node has no parent class or there is no meta associated with the class. + */ +export function getParentClassMeta(requestNode: ts.Node, compiler: NgCompiler): PipeMeta| + DirectiveMeta|null { + const parentClass = getParentClassDeclaration(requestNode); + if (parentClass === undefined) { + return null; + } + return compiler.getMeta(parentClass); +} \ No newline at end of file diff --git a/packages/language-service/ivy/test/definitions_spec.ts b/packages/language-service/ivy/test/definitions_spec.ts index 818c42ac4284b..b1ed3ef3fdc2c 100644 --- a/packages/language-service/ivy/test/definitions_spec.ts +++ b/packages/language-service/ivy/test/definitions_spec.ts @@ -8,7 +8,7 @@ import {initMockFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing'; -import {assertFileNames, createModuleAndProjectWithDeclarations, humanizeDocumentSpanLike, LanguageServiceTestEnv, OpenBuffer} from '../testing'; +import {assertFileNames, assertTextSpans, createModuleAndProjectWithDeclarations, humanizeDocumentSpanLike, LanguageServiceTestEnv, OpenBuffer} from '../testing'; describe('definitions', () => { it('gets definition for template reference in overridden template', () => { @@ -35,7 +35,7 @@ describe('definitions', () => { assertFileNames(Array.from(definitions!), ['app.html']); }); - it('returns the pipe class as definition when checkTypeOfPipes is false', () => { + it('returns the pipe definitions when checkTypeOfPipes is false', () => { initMockFileSystem('Native'); const files = { 'app.ts': ` @@ -57,10 +57,9 @@ describe('definitions', () => { const {textSpan, definitions} = getDefinitionsAndAssertBoundSpan(env, template); expect(template.contents.substr(textSpan.start, textSpan.length)).toEqual('date'); - expect(definitions!.length).toEqual(1); - const [def] = definitions!; - expect(def.textSpan).toContain('DatePipe'); - expect(def.contextSpan).toContain('DatePipe'); + expect(definitions.length).toEqual(3); + assertTextSpans(definitions, ['transform']); + assertFileNames(definitions, ['index.d.ts']); }); it('gets definitions for all inputs when attribute matches more than one', () => { diff --git a/packages/language-service/ivy/test/quick_info_spec.ts b/packages/language-service/ivy/test/quick_info_spec.ts index 1160c80000e41..c8a1f0b9a76ec 100644 --- a/packages/language-service/ivy/test/quick_info_spec.ts +++ b/packages/language-service/ivy/test/quick_info_spec.ts @@ -559,8 +559,13 @@ describe('quick info', () => { // checkTypeOfPipes is set to false when strict templates is false project = env.addProject('test', quickInfoSkeleton(), {strictTemplates: false}); const templateOverride = `

The hero's birthday is {{birthday | da¦te: "MM/dd/yy"}}

`; - expectQuickInfo( - {templateOverride, expectedSpanText: 'date', expectedDisplayString: '(pipe) DatePipe'}); + expectQuickInfo({ + templateOverride, + expectedSpanText: 'date', + expectedDisplayString: + '(pipe) DatePipe.transform(value: string | number | Date, format?: string | undefined, timezone?: ' + + 'string | undefined, locale?: string | undefined): string | null (+2 overloads)' + }); }); it('should still get quick info if there is an invalid css resource', () => { diff --git a/packages/language-service/ivy/test/references_spec.ts b/packages/language-service/ivy/test/references_and_rename_spec.ts similarity index 93% rename from packages/language-service/ivy/test/references_spec.ts rename to packages/language-service/ivy/test/references_and_rename_spec.ts index 70295cac8042f..64f4d2be2220a 100644 --- a/packages/language-service/ivy/test/references_spec.ts +++ b/packages/language-service/ivy/test/references_and_rename_spec.ts @@ -754,11 +754,54 @@ describe('find references and rename locations', () => { } }`; - describe('when cursor is on pipe name', () => { - let file: OpenBuffer; - beforeEach(() => { + for (const checkTypeOfPipes of [true, false]) { + describe(`when cursor is on pipe name, checkTypeOfPipes: ${checkTypeOfPipes}`, () => { + let file: OpenBuffer; + beforeEach(() => { + const files = { + 'app.ts': ` + import {Component} from '@angular/core'; + + @Component({template: '{{birthday | prefixPipe: "MM/dd/yy"}}'}) + export class AppCmp { + birthday = ''; + } + `, + 'prefix-pipe.ts': prefixPipe + }; + + env = LanguageServiceTestEnv.setup(); + const project = createModuleAndProjectWithDeclarations(env, 'test', files); + file = project.openFile('app.ts'); + file.moveCursorToText('prefi¦xPipe:'); + }); + + it('should find references', () => { + const refs = getReferencesAtPosition(file)!; + expect(refs.length).toBe(5); + assertFileNames(refs, ['index.d.ts', 'prefix-pipe.ts', 'app.ts']); + assertTextSpans(refs, ['transform', 'prefixPipe']); + }); + + it('should find rename locations', () => { + const renameLocations = getRenameLocationsAtPosition(file)!; + expect(renameLocations.length).toBe(2); + assertFileNames(renameLocations, ['prefix-pipe.ts', 'app.ts']); + assertTextSpans(renameLocations, ['prefixPipe']); + }); + + it('should get rename info', () => { + const result = file.getRenameInfo() as ts.RenameInfoSuccess; + expect(result.canRename).toEqual(true); + expect(result.displayName).toEqual('prefixPipe'); + }); + }); + } + + describe('when cursor is on pipe name expression', () => { + it('finds rename locations and rename info', () => { const files = { - 'app.ts': ` + '/app.ts': ` import {Component} from '@angular/core'; @Component({template: '{{birthday | prefixPipe: "MM/dd/yy"}}'}) @@ -768,28 +811,49 @@ describe('find references and rename locations', () => { `, 'prefix-pipe.ts': prefixPipe }; - env = LanguageServiceTestEnv.setup(); const project = createModuleAndProjectWithDeclarations(env, 'test', files); - file = project.openFile('app.ts'); + const file = project.openFile('app.ts'); file.moveCursorToText('prefi¦xPipe:'); - }); + const renameLocations = getRenameLocationsAtPosition(file)!; + expect(renameLocations.length).toBe(2); + assertFileNames(renameLocations, ['prefix-pipe.ts', 'app.ts']); + assertTextSpans(renameLocations, ['prefixPipe']); - it('should find references', () => { - const refs = getReferencesAtPosition(file)!; - expect(refs.length).toBe(5); - assertFileNames(refs, ['index.d.ts', 'prefix-pipe.ts', 'app.ts']); - assertTextSpans(refs, ['transform', 'prefixPipe']); + const result = file.getRenameInfo() as ts.RenameInfoSuccess; + expect(result.canRename).toEqual(true); + expect(result.displayName).toEqual('prefixPipe'); }); - it('should find rename locations', () => { - const renameLocations = getRenameLocationsAtPosition(file)!; - expect(renameLocations).toBeUndefined(); + it('finds rename locations in base class', () => { + const files = { + '/base_pipe.ts': ` + import {Pipe, PipeTransform} from '@angular/core'; - // TODO(atscott): Add support for renaming the pipe 'name' - // expect(renameLocations.length).toBe(2); - // assertFileNames(renameLocations, ['prefix-pipe.ts', 'app.ts']); - // assertTextSpans(renameLocations, ['prefixPipe']); + @Pipe({ name: 'basePipe' }) + export class BasePipe implements PipeTransform { + transform(value: string, prefix: string): string; + transform(value: number, prefix: number): number; + transform(value: string|number, prefix: string|number): string|number { + return ''; + } + }`, + 'prefix_pipe.ts': prefixPipe, + 'app.ts': ` + import {Component} from '@angular/core'; + + @Component({template: '{{"a" | prefixPipe: "MM/dd/yy"}}'}) + export class AppCmp { } + ` + }; + env = LanguageServiceTestEnv.setup(); + const project = createModuleAndProjectWithDeclarations(env, 'test', files); + const file = project.openFile('prefix_pipe.ts'); + file.moveCursorToText(`'prefi¦xPipe'`); + const renameLocations = getRenameLocationsAtPosition(file)!; + expect(renameLocations.length).toBe(2); + assertFileNames(renameLocations, ['prefix_pipe.ts', 'app.ts']); + assertTextSpans(renameLocations, ['prefixPipe']); }); }); @@ -884,7 +948,7 @@ describe('find references and rename locations', () => { @Directive({selector: '[string-model]'}) export class OtherDir { - @Input('model') model!: any; + @Input('model') otherDirAliasedInput!: any; } `, 'string-model.ts': dirFileContents, @@ -903,22 +967,20 @@ describe('find references and rename locations', () => { file.moveCursorToText('[mod¦el]'); }); - // TODO(atscott): This test does not pass because the template symbol builder only returns one - // binding. - xit('should find references', () => { + it('should find references', () => { const refs = getReferencesAtPosition(file)!; expect(refs.length).toEqual(3); - assertFileNames(refs, ['string-model.ts', 'app.ts', 'other-dir']); + assertFileNames(refs, ['string-model.ts', 'app.ts', 'other-dir.ts']); assertTextSpans(refs, ['model', 'otherDirAliasedInput']); }); // TODO(atscott): This test fails because template symbol builder only returns one binding. // The result is that rather than returning `undefined` because we don't handle alias inputs, // we return the rename locations for the first binding. - xit('should find rename locations', () => { + it('should find rename locations', () => { const renameLocations = getRenameLocationsAtPosition(file)!; expect(renameLocations).toBeUndefined(); - // TODO(atscott): + // TODO(atscott): The below assertions are the correct ones if we were supporting aliases // expect(renameLocations.length).toEqual(3); // assertFileNames(renameLocations, ['string-model.ts', 'app.ts', 'other-dir']); // assertTextSpans(renameLocations, ['model']); @@ -1194,10 +1256,7 @@ describe('find references and rename locations', () => { file.moveCursorToText('[(mod¦el)]'); const refs = getReferencesAtPosition(file)!; - // Note that this includes the 'model` twice from the template. As with other potential - // duplicates (like if another plugin returns the same span), we expect the LS clients to filter - // these out themselves. - expect(refs.length).toEqual(4); + expect(refs.length).toEqual(3); assertFileNames(refs, ['dir.ts', 'app.ts']); assertTextSpans(refs, ['model', 'modelChange']); }); @@ -1284,7 +1343,7 @@ describe('find references and rename locations', () => { it('gets references to all matching directives', () => { const refs = getReferencesAtPosition(file)!; - expect(refs.length).toBe(8); + expect(refs.length).toBe(7); assertTextSpans(refs, ['
', 'Dir', 'Dir2']); assertFileNames(refs, ['app.ts', 'dir.ts', 'dir2.ts']); }); diff --git a/packages/language-service/ivy/test/ts_utils_spec.ts b/packages/language-service/ivy/test/ts_utils_spec.ts new file mode 100644 index 0000000000000..6922a944970da --- /dev/null +++ b/packages/language-service/ivy/test/ts_utils_spec.ts @@ -0,0 +1,86 @@ +/** + * @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 * as ts from 'typescript'; +import {LanguageServiceTestEnv, OpenBuffer, Project} from '../testing'; +import {collectMemberMethods, findTightestNode} from '../ts_utils'; + +describe('ts utils', () => { + describe('collectMemberMethods', () => { + beforeEach(() => { + initMockFileSystem('Native'); + }); + + it('gets only methods in class, not getters, setters, or properties', () => { + const files = { + 'app.ts': ` + export class AppCmp { + prop!: string; + get myString(): string { + return ''; + } + set myString(v: string) { + } + + one() {} + two() {} + }`, + }; + const env = LanguageServiceTestEnv.setup(); + const project = env.addProject('test', files); + const appFile = project.openFile('app.ts'); + appFile.moveCursorToText('AppC¦mp'); + const memberMethods = getMemberMethodNames(project, appFile); + expect(memberMethods).toEqual(['one', 'two']); + }); + + it('gets inherited methods in class', () => { + const files = { + 'app.ts': ` + export class BaseClass { + baseMethod() {} + } + export class AppCmp extends BaseClass {}`, + }; + const env = LanguageServiceTestEnv.setup(); + const project = env.addProject('test', files); + const appFile = project.openFile('app.ts'); + appFile.moveCursorToText('AppC¦mp'); + const memberMethods = getMemberMethodNames(project, appFile); + expect(memberMethods).toEqual(['baseMethod']); + }); + + it('does not return duplicates if base method is overridden', () => { + const files = { + 'app.ts': ` + export class BaseClass { + baseMethod() {} + } + export class AppCmp extends BaseClass { + baseMethod() {} + }`, + }; + const env = LanguageServiceTestEnv.setup(); + const project = env.addProject('test', files); + const appFile = project.openFile('app.ts'); + appFile.moveCursorToText('AppC¦mp'); + const memberMethods = getMemberMethodNames(project, appFile); + expect(memberMethods).toEqual(['baseMethod']); + }); + + function getMemberMethodNames(project: Project, file: OpenBuffer): string[] { + const sf = project.getSourceFile('app.ts')!; + const node = findTightestNode(sf, file.cursor)!; + expect(ts.isClassDeclaration(node.parent)).toBe(true); + return collectMemberMethods(node.parent as ts.ClassDeclaration, project.getTypeChecker()) + .map(m => m.name.getText()) + .sort(); + } + }); +}); diff --git a/packages/language-service/ivy/test/type_definitions_spec.ts b/packages/language-service/ivy/test/type_definitions_spec.ts index ebe6ff11fac21..a20c0d30d20dd 100644 --- a/packages/language-service/ivy/test/type_definitions_spec.ts +++ b/packages/language-service/ivy/test/type_definitions_spec.ts @@ -8,7 +8,7 @@ import {initMockFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing'; -import {humanizeDocumentSpanLike, LanguageServiceTestEnv, Project} from '../testing'; +import {assertFileNames, assertTextSpans, humanizeDocumentSpanLike, LanguageServiceTestEnv, Project} from '../testing'; describe('type definitions', () => { let env: LanguageServiceTestEnv; @@ -33,11 +33,10 @@ describe('type definitions', () => { const project = env.addProject('test', files, {strictTemplates: false}); const definitions = getTypeDefinitionsAndAssertBoundSpan(project, {templateOverride: '{{"1/1/2020" | dat¦e}}'}); - expect(definitions!.length).toEqual(1); + expect(definitions!.length).toEqual(3); - const [def] = definitions; - expect(def.textSpan).toContain('DatePipe'); - expect(def.contextSpan).toContain('DatePipe'); + assertTextSpans(definitions, ['transform']); + assertFileNames(definitions, ['index.d.ts']); }); function getTypeDefinitionsAndAssertBoundSpan( diff --git a/packages/language-service/ivy/testing/src/project.ts b/packages/language-service/ivy/testing/src/project.ts index 307eadf66de04..b3dc513405e20 100644 --- a/packages/language-service/ivy/testing/src/project.ts +++ b/packages/language-service/ivy/testing/src/project.ts @@ -117,6 +117,15 @@ export class Project { return this.buffers.get(projectFileName)!; } + getSourceFile(projectFileName: string): ts.SourceFile|undefined { + const fileName = absoluteFrom(`/${this.name}/${projectFileName}`); + return this.tsProject.getSourceFile(this.projectService.toPath(fileName)); + } + + getTypeChecker(): ts.TypeChecker { + return this.ngLS.compilerFactory.getOrCreate().getCurrentProgram().getTypeChecker(); + } + getDiagnosticsForFile(projectFileName: string): ts.Diagnostic[] { const fileName = absoluteFrom(`/${this.name}/${projectFileName}`); const diagnostics: ts.Diagnostic[] = []; diff --git a/packages/language-service/ivy/ts_utils.ts b/packages/language-service/ivy/ts_utils.ts index ef52343337fd3..688535247c6b6 100644 --- a/packages/language-service/ivy/ts_utils.ts +++ b/packages/language-service/ivy/ts_utils.ts @@ -79,3 +79,18 @@ export function getClassDeclFromDecoratorProp(propAsgnNode: ts.PropertyAssignmen const classDeclNode = decorator.parent; return classDeclNode; } + +/** + * Collects all member methods, including those from base classes. + */ +export function collectMemberMethods( + clazz: ts.ClassDeclaration, typeChecker: ts.TypeChecker): ts.MethodDeclaration[] { + const members: ts.MethodDeclaration[] = []; + const apparentProps = typeChecker.getTypeAtLocation(clazz).getApparentProperties(); + for (const prop of apparentProps) { + if (ts.isMethodDeclaration(prop.valueDeclaration) && prop.valueDeclaration) { + members.push(prop.valueDeclaration); + } + } + return members; +}