diff --git a/packages/language-service/api.ts b/packages/language-service/api.ts index f8f377713b6..74bf064b6a8 100644 --- a/packages/language-service/api.ts +++ b/packages/language-service/api.ts @@ -99,6 +99,265 @@ export interface LinkedEditingRanges { wordPattern?: string; } +/** + * A display part for interactive inlay hints. + * When clicked, can navigate to the definition of the type/parameter. + */ +export interface InlayHintDisplayPart { + /** The text to display */ + text: string; + /** Optional navigation target span */ + span?: { + /** Start offset in the target file */ + start: number; + /** Length of the span */ + length: number; + }; + /** Optional target file path for navigation */ + file?: string; +} + +/** + * Represents an Angular-specific inlay hint to be displayed in the editor. + */ +export interface AngularInlayHint { + /** Offset position where the hint should appear */ + position: number; + /** + * The text to display. + * For non-interactive hints, this contains the full hint text. + * For interactive hints, this may be empty and displayParts is used instead. + */ + text: string; + /** Kind of hint: 'Type' for type annotations, 'Parameter' for parameter names */ + kind: 'Type' | 'Parameter'; + /** Whether to add padding before the hint */ + paddingLeft?: boolean; + /** Whether to add padding after the hint */ + paddingRight?: boolean; + /** Optional tooltip documentation */ + tooltip?: string; + /** + * Display parts for interactive hints. + * When present, these parts can be clicked to navigate to type/parameter definitions. + * Used when interactiveInlayHints is enabled. + */ + displayParts?: InlayHintDisplayPart[]; +} + +/** + * Configuration for which Angular inlay hints to show. + * These options are designed to align with TypeScript's inlay hints configuration + * where applicable to Angular templates. + */ +export interface InlayHintsConfig { + // ═══════════════════════════════════════════════════════════════════════════ + // VARIABLE TYPE HINTS - equivalent to TypeScript's includeInlayVariableTypeHints + // ═══════════════════════════════════════════════════════════════════════════ + + /** Show type hints for @for loop variables: `@for (item: Item of items)` */ + forLoopVariableTypes?: boolean; + + /** + * Show type hints for @if alias variables: `@if (data; as result: Data)` + * + * Can be a boolean to enable/disable all @if alias hints, or an object for fine-grained control: + * - `simpleExpressions`: Show hints for simple variable references like `@if (data; as result)` + * - `complexExpressions`: Show hints for complex expressions like `@if (data == 2; as b)` or `@if (data.prop; as p)` + * + * When set to true, shows hints for all @if aliases. + * When set to 'complex', only shows hints for complex expressions (property access, comparisons, calls, etc.) + */ + ifAliasTypes?: + | boolean + | 'complex' + | { + /** Show hints for simple variable references: @if (data; as result). Default: true */ + simpleExpressions?: boolean; + /** Show hints for complex expressions: @if (data.prop; as p), @if (a == b; as c). Default: true */ + complexExpressions?: boolean; + }; + + /** Show type hints for @let declarations: `@let count: number = items.length` */ + letDeclarationTypes?: boolean; + + /** Show type hints for template reference variables: `#ref: HTMLInputElement` */ + referenceVariableTypes?: boolean; + + /** + * Suppress hints when variable name matches the type name (case-insensitive). + * Equivalent to TypeScript's `includeInlayVariableTypeHintsWhenTypeMatchesName`. + * When false, skips hints like `@let user: User = getUser()` where name matches type. + * @default true + */ + variableTypeHintsWhenTypeMatchesName?: boolean; + + // ═══════════════════════════════════════════════════════════════════════════ + // ARROW FUNCTION TYPE HINTS - equivalent to TypeScript's function type hints + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Show type hints for arrow function parameters in templates. + * Equivalent to TypeScript's `includeInlayFunctionParameterTypeHints`. + * + * When enabled, shows: `(a: number, b: string) => a + b` where types are inferred. + * @default true + */ + arrowFunctionParameterTypes?: boolean; + + /** + * Show return type hints for arrow functions in templates. + * Equivalent to TypeScript's `includeInlayFunctionLikeReturnTypeHints`. + * + * When enabled, shows: `(a, b): number => a + b` where return type is inferred. + * @default true + */ + arrowFunctionReturnTypes?: boolean; + + // ═══════════════════════════════════════════════════════════════════════════ + // PARAMETER NAME HINTS - equivalent to TypeScript's includeInlayParameterNameHints + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Show parameter name hints for function/method arguments in expressions. + * Equivalent to TypeScript's `includeInlayParameterNameHints`. + * + * When enabled, shows: `handleClick(event: $event)` where 'event' is the parameter name. + * Options: + * - 'none': No parameter name hints + * - 'literals': Only for literal arguments (strings, numbers, booleans) + * - 'all': For all arguments + * @default 'all' + */ + parameterNameHints?: 'none' | 'literals' | 'all'; + + /** + * Show parameter hints even when argument name matches parameter name. + * Equivalent to TypeScript's `includeInlayParameterNameHintsWhenArgumentMatchesName`. + * When false, skips hints like `onClick(event)` where arg name matches param name. + * @default false + */ + parameterNameHintsWhenArgumentMatchesName?: boolean; + + // ═══════════════════════════════════════════════════════════════════════════ + // EVENT TYPE HINTS - Angular-specific for event bindings + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Show type hints for event binding $event parameter. + * Works with @Output() EventEmitter, output() OutputEmitterRef, and model() changes. + * Example: `(click: MouseEvent)`, `(valueChange: string)` + * + * Can be a boolean to enable/disable all event hints, or an object for fine-grained control: + * - `nativeEvents`: Show hints for native HTML events (click, input, etc.) + * - `componentEvents`: Show hints for custom component/directive events + * - `animationEvents`: Show hints for animation events (@trigger.done) + */ + eventParameterTypes?: + | boolean + | { + /** Show hints for native HTML events (click, input, keydown, etc.). Default: true */ + nativeEvents?: boolean; + /** Show hints for custom component/directive output events. Default: true */ + componentEvents?: boolean; + /** Show hints for animation events (@trigger.start, @trigger.done). Default: true */ + animationEvents?: boolean; + }; + + // ═══════════════════════════════════════════════════════════════════════════ + // PIPE AND BINDING TYPE HINTS - Angular-specific + // ═══════════════════════════════════════════════════════════════════════════ + + /** Show type hints for pipe output types: `{{ value | async: Observable }}` */ + pipeOutputTypes?: boolean; + + /** + * Show type hints for property/input binding types. + * Works with @Input(), input(), input.required(), and model() inputs. + * Example: `[disabled: boolean]="isDisabled"` + * + * Can be a boolean to enable/disable all property hints, or an object for fine-grained control: + * - `nativeProperties`: Show hints for native DOM properties ([disabled], [hidden], etc.) + * - `componentInputs`: Show hints for component/directive @Input() and input() bindings + */ + propertyBindingTypes?: + | boolean + | { + /** Show hints for native DOM properties. Default: true */ + nativeProperties?: boolean; + /** Show hints for component/directive inputs. Default: true */ + componentInputs?: boolean; + }; + + /** + * Show WritableSignal type for two-way bindings with model() signals. + * When true: `[(checked: WritableSignal)]="checkboxSignal"` + * When false: `[(checked: boolean)]="checkboxSignal"` + * @default true + */ + twoWayBindingSignalTypes?: boolean; + + // ═══════════════════════════════════════════════════════════════════════════ + // VISUAL DIFFERENTIATION OPTIONS + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Add visual indicator for input.required() bindings. + * Options: + * - 'none': No special indicator + * - 'asterisk': Add asterisk suffix `[user*: User]` + * - 'exclamation': Add exclamation suffix `[user: User!]` + * @default 'none' + */ + requiredInputIndicator?: 'none' | 'asterisk' | 'exclamation'; + + // ═══════════════════════════════════════════════════════════════════════════ + // INTERACTIVE HINTS - equivalent to TypeScript's interactiveInlayHints + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Enable clickable hints that navigate to type/parameter definitions. + * Equivalent to TypeScript's `interactiveInlayHints`. + * @default false + */ + interactiveInlayHints?: boolean; + + // ═══════════════════════════════════════════════════════════════════════════ + // HOST LISTENER ARGUMENT TYPE HINTS - Angular-specific for @HostListener + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Show type hints for @HostListener argument expressions. + * Example: `@HostListener('click', ['$event.target: EventTarget | null', '$event.clientX: number'])` + * + * When enabled, shows the inferred type for each expression passed in the decorator arguments. + * @default true + */ + hostListenerArgumentTypes?: boolean; + + // ═══════════════════════════════════════════════════════════════════════════ + // CONTROL FLOW BLOCK TYPE HINTS - Angular-specific for new control flow syntax + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Show type hints for @switch block expressions. + * Example: `@switch (status: Status) { @case (Status.Active) { ... } }` + * + * When enabled, shows the inferred type of the switch expression value. + * @default true + */ + switchExpressionTypes?: boolean; + + /** + * Show type hints for @defer block trigger expressions. + * Example: `@defer (when isVisible: boolean) { ... }` + * + * When enabled, shows the inferred type for 'when' trigger conditions. + * @default true + */ + deferTriggerTypes?: boolean; +} + /** * `NgLanguageService` describes an instance of an Angular language service, * whose API surface is a strict superset of TypeScript's language service. @@ -136,6 +395,26 @@ export interface NgLanguageService extends ts.LanguageService { ): GetTemplateLocationForComponentResponse; getTypescriptLanguageService(): ts.LanguageService; + /** + * Provide Angular-specific inlay hints for templates. + * + * Returns hints for: + * - @for loop variable types: `@for (user: User of users)` + * - @if alias types: `@if (data; as result: ApiResult)` + * - Event parameter types: `(click)="onClick($event: MouseEvent)"` + * - Pipe output types + * - @let declaration types + * + * @param fileName The file to get inlay hints for + * @param span The text span to get hints within + * @param config Optional configuration for which hints to show + */ + getAngularInlayHints( + fileName: string, + span: ts.TextSpan, + config?: InlayHintsConfig, + ): AngularInlayHint[]; + applyRefactoring( fileName: string, positionOrRange: number | ts.TextRange, diff --git a/packages/language-service/src/inlay_hints.ts b/packages/language-service/src/inlay_hints.ts new file mode 100644 index 00000000000..36831ae1c86 --- /dev/null +++ b/packages/language-service/src/inlay_hints.ts @@ -0,0 +1,2054 @@ +/** + * @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.dev/license + */ + +import { + AST, + ASTWithSource, + ArrowFunction, + BindingPipe, + BindingType, + Call, + ImplicitReceiver, + LiteralArray, + LiteralMap, + LiteralPrimitive, + SafeCall, + ParsedEventType, + PropertyRead, + SpreadElement, + TemplateLiteral, + TmplAstBoundAttribute, + TmplAstBoundDeferredTrigger, + TmplAstBoundEvent, + TmplAstBoundText, + TmplAstDeferredBlock, + TmplAstForLoopBlock, + TmplAstIfBlockBranch, + TmplAstLetDeclaration, + TmplAstNode, + TmplAstSwitchBlock, + TmplAstTextAttribute, + TmplAstVariable, + TmplAstReference, + tmplAstVisitAll, + RecursiveAstVisitor, + TmplAstRecursiveVisitor, + Unary, +} from '@angular/compiler'; +import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core'; +import { + SymbolKind, + VariableSymbol, + PipeSymbol, + OutputBindingSymbol, + InputBindingSymbol, + LetDeclarationSymbol, + ReferenceSymbol, + DomBindingSymbol, + ElementSymbol, + ExpressionSymbol, +} from '@angular/compiler-cli/src/ngtsc/typecheck/api'; +import {TemplateTypeChecker} from '@angular/compiler-cli/src/ngtsc/typecheck/api/checker'; +import {findFirstMatchingNode} from '@angular/compiler-cli/src/ngtsc/typecheck/src/comments'; +import ts from 'typescript'; + +import {TypeCheckInfo} from './utils'; + +// Re-export types from API for internal use +export type {AngularInlayHint, InlayHintsConfig, InlayHintDisplayPart} from '../api'; +import type {AngularInlayHint, InlayHintsConfig, InlayHintDisplayPart} from '../api'; + +/** + * Default configuration - aligned with TypeScript's defaults where applicable. + */ +const DEFAULT_CONFIG: Required = { + // Variable type hints (like TS includeInlayVariableTypeHints) + forLoopVariableTypes: true, + ifAliasTypes: true, + letDeclarationTypes: true, + referenceVariableTypes: true, + variableTypeHintsWhenTypeMatchesName: true, + + // Arrow function type hints (like TS function type hints) + arrowFunctionParameterTypes: true, + arrowFunctionReturnTypes: true, + + // Parameter name hints (like TS includeInlayParameterNameHints) + parameterNameHints: 'all', + parameterNameHintsWhenArgumentMatchesName: false, + + // Event type hints (Angular-specific) + eventParameterTypes: true, + + // Pipe and binding hints (Angular-specific) + pipeOutputTypes: true, + propertyBindingTypes: true, + twoWayBindingSignalTypes: true, + + // Visual differentiation + requiredInputIndicator: 'none', + + // Interactive hints (like TS interactiveInlayHints) + interactiveInlayHints: false, + + // Host listener argument types (Angular-specific) + hostListenerArgumentTypes: true, + + // Control flow block hints (Angular-specific) + switchExpressionTypes: true, + deferTriggerTypes: true, +}; + +const globalDomTypeCacheByChecker = new WeakMap>(); + +/** + * Normalize the eventParameterTypes config to a structured format. + */ +function normalizeEventConfig( + config: + | boolean + | {nativeEvents?: boolean; componentEvents?: boolean; animationEvents?: boolean} + | undefined, +): { + enabled: boolean; + nativeEvents: boolean; + componentEvents: boolean; + animationEvents: boolean; +} { + if (config === false) { + return {enabled: false, nativeEvents: false, componentEvents: false, animationEvents: false}; + } + if (config === true || config === undefined) { + return {enabled: true, nativeEvents: true, componentEvents: true, animationEvents: true}; + } + return { + enabled: true, + nativeEvents: config.nativeEvents ?? true, + componentEvents: config.componentEvents ?? true, + animationEvents: config.animationEvents ?? true, + }; +} + +/** + * Normalize the propertyBindingTypes config to a structured format. + */ +function normalizePropertyConfig( + config: boolean | {nativeProperties?: boolean; componentInputs?: boolean} | undefined, +): { + enabled: boolean; + nativeProperties: boolean; + componentInputs: boolean; +} { + if (config === false) { + return {enabled: false, nativeProperties: false, componentInputs: false}; + } + if (config === true || config === undefined) { + return {enabled: true, nativeProperties: true, componentInputs: true}; + } + return { + enabled: true, + nativeProperties: config.nativeProperties ?? true, + componentInputs: config.componentInputs ?? true, + }; +} + +/** + * Normalize the ifAliasTypes config to a structured format. + */ +function normalizeIfAliasConfig(config: InlayHintsConfig['ifAliasTypes']): { + enabled: boolean; + simpleExpressions: boolean; + complexExpressions: boolean; +} { + if (config === false) { + return {enabled: false, simpleExpressions: false, complexExpressions: false}; + } + if (config === true || config === undefined) { + return {enabled: true, simpleExpressions: true, complexExpressions: true}; + } + if (config === 'complex') { + // Only show hints for complex expressions + return {enabled: true, simpleExpressions: false, complexExpressions: true}; + } + return { + enabled: true, + simpleExpressions: config.simpleExpressions ?? true, + complexExpressions: config.complexExpressions ?? true, + }; +} + +/** + * Check if an @if expression is a "simple" expression. + * Simple: @if (variable; as alias) - just a property read from implicit receiver + * Complex: @if (var == 2; as alias), @if (var.prop; as alias), @if (fn(); as alias) + */ +function isSimpleIfExpression(expression: AST | null): boolean { + if (!expression) return true; + + // Unwrap ASTWithSource + const expr = expression instanceof ASTWithSource ? expression.ast : expression; + + // Simple case: PropertyRead with ImplicitReceiver (e.g., @if (data; as result)) + if (expr instanceof PropertyRead && expr.receiver instanceof ImplicitReceiver) { + return true; + } + + // Everything else is complex + return false; +} + +/** + * Get Angular-specific inlay hints for a template. + */ +export function getInlayHintsForTemplate( + compiler: NgCompiler, + typeCheckInfo: TypeCheckInfo, + span: ts.TextSpan, + config: InlayHintsConfig = {}, +): AngularInlayHint[] { + const mergedConfig = {...DEFAULT_CONFIG, ...config}; + const hints: AngularInlayHint[] = []; + const ttc = compiler.getTemplateTypeChecker(); + const typeChecker = compiler.getCurrentProgram().getTypeChecker(); + + // Get the Type Check Block for accessing resolved types + // The TCB has TypeScript's resolved types for all expressions, including + // overloaded function calls and generic type inference + const tcb = ttc.getTypeCheckBlock(typeCheckInfo.declaration); + + // Get the template AST (may be null for directives without templates) + const template = ttc.getTemplate(typeCheckInfo.declaration); + + // Helper to check if a position is within our requested span + const isInSpan = (pos: number): boolean => { + return pos >= span.start && pos < span.start + span.length; + }; + + // Helper to format type string (remove unnecessary verbosity) + const formatType = (type: ts.Type): string => { + let typeStr = typeChecker.typeToString( + type, + undefined, + ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.OmitParameterModifiers, + ); + // Simplify common patterns + typeStr = typeStr.replace(/import\([^)]+\)\./g, ''); // Remove import paths + return typeStr; + }; + + /** + * Try to get the declaration info from a type for interactive hints. + * Returns the first declaration's location if available. + */ + const getTypeDeclarationInfo = ( + type: ts.Type, + ): {file: string; start: number; length: number} | null => { + const symbol = type.getSymbol(); + if (symbol) { + const declarations = symbol.getDeclarations(); + if (declarations && declarations.length > 0) { + const decl = declarations[0]; + const sourceFile = decl.getSourceFile(); + return { + file: sourceFile.fileName, + start: decl.getStart(), + length: decl.getWidth(), + }; + } + } + return null; + }; + + /** + * Create a type hint with optional interactivity. + * When interactiveInlayHints is enabled and the type has a declaration, + * clicking the hint navigates to the type definition. + * + * @param position - Position in the template where the hint should appear + * @param type - The ts.Type object (required for interactive navigation) + * @param typeStr - The formatted type string to display + * @param paddingLeft - Add padding before the hint + * @param paddingRight - Add padding after the hint + */ + const createTypeHint = ( + position: number, + type: ts.Type, + typeStr: string, + paddingLeft: boolean = false, + paddingRight: boolean = false, + ): AngularInlayHint => { + // Try to get the type's declaration for interactive hints + if (mergedConfig.interactiveInlayHints) { + const declInfo = getTypeDeclarationInfo(type); + if (declInfo) { + return { + position, + text: '', + kind: 'Type', + paddingLeft, + paddingRight, + displayParts: [ + { + text: ': ', + span: { + start: declInfo.start, + length: declInfo.length, + }, + file: declInfo.file, + }, + { + text: typeStr, + span: { + start: declInfo.start, + length: declInfo.length, + }, + file: declInfo.file, + }, + ], + }; + } + } + + // Non-interactive hint + return { + position, + text: `: ${typeStr}`, + kind: 'Type', + paddingLeft, + paddingRight, + }; + }; + + /** + * Create a type hint from a type string with optional suffix (like "!" for required). + * When interactiveInlayHints is enabled and the type has a declaration, + * clicking the hint navigates to the type definition. + * + * This is useful when we have a ts.Type but need to add extra text like + * required indicators or wrapper types. + * + * @param position - Position in the template where the hint should appear + * @param type - The ts.Type object (can be null if type not available) + * @param typeStr - The formatted type string to display + * @param suffix - Optional suffix to add after the type (e.g., "!" for required) + * @param prefix - Optional prefix before the colon (defaults to empty) + */ + const createTypeHintWithSuffix = ( + position: number, + type: ts.Type | null, + typeStr: string, + suffix: string = '', + prefix: string = '', + ): AngularInlayHint => { + // Try to get the type's declaration for interactive hints + if (mergedConfig.interactiveInlayHints && type) { + const declInfo = getTypeDeclarationInfo(type); + if (declInfo) { + const parts: InlayHintDisplayPart[] = []; + if (prefix) { + parts.push({text: prefix}); + } + parts.push({ + text: ': ', + span: { + start: declInfo.start, + length: declInfo.length, + }, + file: declInfo.file, + }); + parts.push({ + text: typeStr, + span: { + start: declInfo.start, + length: declInfo.length, + }, + file: declInfo.file, + }); + if (suffix) { + parts.push({text: suffix}); + } + return { + position, + text: '', + kind: 'Type', + paddingLeft: false, + paddingRight: false, + displayParts: parts, + }; + } + } + + // Non-interactive hint + return { + position, + text: `${prefix}: ${typeStr}${suffix}`, + kind: 'Type', + paddingLeft: false, + paddingRight: false, + }; + }; + + /** + * Check if the type name matches the variable name (case-insensitive). + * TypeScript skips variable type hints when the type name matches the variable name. + * e.g., `const user: User` - hint is skipped because 'user' matches 'User' + */ + const typeMatchesName = (typeName: string, variableName: string): boolean => { + // Handle generic types like User - extract the base type name + const baseTypeName = typeName.split('<')[0].trim(); + return baseTypeName.toLowerCase() === variableName.toLowerCase(); + }; + + /** + * Check if a variable type hint should be shown based on config. + * Returns false if variableTypeHintsWhenTypeMatchesName is false and the names match. + */ + const shouldShowVariableTypeHint = (typeStr: string, varName: string): boolean => { + if (!mergedConfig.variableTypeHintsWhenTypeMatchesName && typeMatchesName(typeStr, varName)) { + return false; + } + return true; + }; + + // Variables from @for/@if alias are visited both in specialized visitors + // and again through the generic variable visitor. Track these nodes to avoid + // emitting duplicates from the recursive traversal model. + const processedVariables = new Set(); + + // Visitor to collect inlay hints from template nodes + class InlayHintVisitor extends TmplAstRecursiveVisitor { + override visitForLoopBlock(block: TmplAstForLoopBlock): void { + if (mergedConfig.forLoopVariableTypes) { + // Get type hint for the main loop variable + const itemVar = block.item; + processedVariables.add(itemVar); + if (itemVar.keySpan && isInSpan(itemVar.keySpan.start.offset)) { + const symbol = ttc.getSymbolOfNode(itemVar, typeCheckInfo.declaration); + if (symbol && symbol.kind === SymbolKind.Variable) { + const varSymbol = symbol as VariableSymbol; + const typeStr = formatType(varSymbol.tsType); + // Skip hint if type matches variable name (like TypeScript does) + if (shouldShowVariableTypeHint(typeStr, itemVar.name)) { + // Position after the variable name + hints.push( + createTypeHint(itemVar.keySpan.end.offset, varSymbol.tsType, typeStr, false, true), + ); + } + } + } + + // Get type hints for context variables ($index, $count, etc.) + // Only process context variables that have non-empty keySpans. + // The parser creates implicit context variables with empty spans for all + // allowed names ($index, $first, $last, $even, $odd, $count), but only + // explicitly aliased ones (e.g., `let idx = $index`) have meaningful spans. + for (const contextVar of block.contextVariables) { + processedVariables.add(contextVar); + // Skip implicit context variables with empty spans (start === end) + if ( + !contextVar.keySpan || + contextVar.keySpan.start.offset === contextVar.keySpan.end.offset + ) { + continue; + } + + if (isInSpan(contextVar.keySpan.start.offset)) { + const symbol = ttc.getSymbolOfNode(contextVar, typeCheckInfo.declaration); + if (symbol && symbol.kind === SymbolKind.Variable) { + const varSymbol = symbol as VariableSymbol; + const typeStr = formatType(varSymbol.tsType); + // Skip hint if type matches variable name (like TypeScript does) + if (shouldShowVariableTypeHint(typeStr, contextVar.name)) { + hints.push( + createTypeHint( + contextVar.keySpan.end.offset, + varSymbol.tsType, + typeStr, + false, + true, + ), + ); + } + } + } + } + } + + super.visitForLoopBlock(block); + } + + override visitIfBlockBranch(branch: TmplAstIfBlockBranch): void { + if (branch.expressionAlias) { + processedVariables.add(branch.expressionAlias); + } + + const ifAliasConfig = normalizeIfAliasConfig(mergedConfig.ifAliasTypes); + if (ifAliasConfig.enabled && branch.expressionAlias) { + // Get type for @if alias: @if (condition; as result) + const aliasVar = branch.expressionAlias; + + // Check if this expression should show a hint based on config + const isSimple = isSimpleIfExpression(branch.expression); + const shouldShow = isSimple + ? ifAliasConfig.simpleExpressions + : ifAliasConfig.complexExpressions; + + if (shouldShow && aliasVar.keySpan && isInSpan(aliasVar.keySpan.start.offset)) { + const symbol = ttc.getSymbolOfNode(aliasVar, typeCheckInfo.declaration); + if (symbol && symbol.kind === SymbolKind.Variable) { + const varSymbol = symbol as VariableSymbol; + const typeStr = formatType(varSymbol.tsType); + // Skip hint if type matches variable name (like TypeScript does) + if (shouldShowVariableTypeHint(typeStr, aliasVar.name)) { + hints.push( + createTypeHint(aliasVar.keySpan.end.offset, varSymbol.tsType, typeStr, false, true), + ); + } + } + } + } + + super.visitIfBlockBranch(branch); + } + + override visitBoundEvent(event: TmplAstBoundEvent): void { + // Show event type hint after event name: (click: MouseEvent) + const eventConfig = normalizeEventConfig(mergedConfig.eventParameterTypes); + if (eventConfig.enabled && event.keySpan) { + // Two-way bindings already have a property-side hint on visitBoundAttribute. + // Skip event-side type hint to avoid duplicate labels like `[(value: T: T)]`. + if (event.type === ParsedEventType.TwoWay) { + // Continue into handler for parameter-name hints below. + } else if (isInSpan(event.keySpan.start.offset)) { + // Determine the event source to apply fine-grained config + const symbol = ttc.getSymbolOfNode(event, typeCheckInfo.declaration); + const isComponentEvent = symbol?.kind === SymbolKind.Output; + const isAnimationEvent = + event.type === ParsedEventType.LegacyAnimation || + event.type === ParsedEventType.Animation; + const isNativeEvent = !isComponentEvent && !isAnimationEvent; + + // Check if this event type should show hints based on config + const shouldShow = + (isNativeEvent && eventConfig.nativeEvents) || + (isComponentEvent && eventConfig.componentEvents) || + (isAnimationEvent && eventConfig.animationEvents); + + if (shouldShow) { + // Get the $event type from the template type checker + const eventTypeResult = getEventType(event, typeCheckInfo, ttc, typeChecker); + if (eventTypeResult) { + // Use createTypeHint for interactive hints if we have the ts.Type + if (eventTypeResult.tsType) { + hints.push( + createTypeHint( + event.keySpan.end.offset, + eventTypeResult.tsType, + eventTypeResult.typeStr, + ), + ); + } else { + // Fallback for types without ts.Type (like AnimationCallbackEvent) + hints.push({ + position: event.keySpan.end.offset, + text: `: ${eventTypeResult.typeStr}`, + kind: 'Type', + paddingLeft: false, + paddingRight: false, + }); + } + } + } + } + } + + // Show function argument parameter name hints for method calls in the handler + if (mergedConfig.parameterNameHints !== 'none') { + const visitor = new FunctionArgumentVisitor( + typeCheckInfo, + ttc, + typeChecker, + tcb, + hints, + isInSpan, + formatType, + mergedConfig, + createTypeHint, + ); + event.handler.visit(visitor); + } + } + + override visitVariable(variable: TmplAstVariable): void { + if (processedVariables.has(variable)) { + return; + } + + // This handles template variables like `let-item` on ng-template + // and structural directive variables like `*ngFor="let item of items"` + if ( + mergedConfig.forLoopVariableTypes && + variable.keySpan && + isInSpan(variable.keySpan.start.offset) + ) { + const symbol = ttc.getSymbolOfNode(variable, typeCheckInfo.declaration); + if (symbol && symbol.kind === SymbolKind.Variable) { + const varSymbol = symbol as VariableSymbol; + const typeStr = formatType(varSymbol.tsType); + // Skip hint if type matches variable name (like TypeScript does) + if (shouldShowVariableTypeHint(typeStr, variable.name)) { + hints.push( + createTypeHint(variable.keySpan.end.offset, varSymbol.tsType, typeStr, false, true), + ); + } + } + } + } + + override visitLetDeclaration(decl: TmplAstLetDeclaration): void { + // Handle @let declarations: @let result = expression + if ( + mergedConfig.letDeclarationTypes && + decl.nameSpan && + isInSpan(decl.nameSpan.start.offset) + ) { + const symbol = ttc.getSymbolOfNode(decl, typeCheckInfo.declaration); + if (symbol && symbol.kind === SymbolKind.LetDeclaration) { + const letSymbol = symbol as LetDeclarationSymbol; + const typeStr = formatType(letSymbol.tsType); + // Skip hint if type matches variable name (like TypeScript does) + if (shouldShowVariableTypeHint(typeStr, decl.name)) { + hints.push( + createTypeHint(decl.nameSpan.end.offset, letSymbol.tsType, typeStr, false, true), + ); + } + } + } + } + + override visitReference(reference: TmplAstReference): void { + // Handle template reference variables: #ref, #myInput="matInput" + if ( + mergedConfig.referenceVariableTypes && + reference.keySpan && + isInSpan(reference.keySpan.start.offset) + ) { + const symbol = ttc.getSymbolOfNode(reference, typeCheckInfo.declaration); + if (symbol && symbol.kind === SymbolKind.Reference) { + const refSymbol = symbol as ReferenceSymbol; + const typeStr = formatType(refSymbol.tsType); + // Skip hint if type matches variable name (like TypeScript does) + if (shouldShowVariableTypeHint(typeStr, reference.name)) { + hints.push( + createTypeHint(reference.keySpan.end.offset, refSymbol.tsType, typeStr, false, true), + ); + } + } + } + } + + override visitBoundText(text: TmplAstBoundText): void { + // Handle interpolations: {{ value | pipe }} + if (mergedConfig.pipeOutputTypes) { + const pipeVisitor = new PipeExpressionVisitor( + ttc, + typeChecker, + typeCheckInfo.declaration, + tcb, + hints, + isInSpan, + formatType, + mergedConfig, + createTypeHint, + ); + text.value.visit(pipeVisitor); + } + + // Show function argument parameter name hints for method calls in interpolations + if (mergedConfig.parameterNameHints !== 'none') { + const argVisitor = new FunctionArgumentVisitor( + typeCheckInfo, + ttc, + typeChecker, + tcb, + hints, + isInSpan, + formatType, + mergedConfig, + createTypeHint, + ); + text.value.visit(argVisitor); + } + } + + override visitBoundAttribute(attribute: TmplAstBoundAttribute): void { + // Handle property bindings: [prop]="value | pipe" + if (mergedConfig.pipeOutputTypes) { + const pipeVisitor = new PipeExpressionVisitor( + ttc, + typeChecker, + typeCheckInfo.declaration, + tcb, + hints, + isInSpan, + formatType, + mergedConfig, + createTypeHint, + ); + attribute.value.visit(pipeVisitor); + } + + // Handle input binding type hints: [input: Type]="value" or [(model): WritableSignal]="signal" + // Works with @Input(), input(), input.required(), and model() bindings + const propertyConfig = normalizePropertyConfig(mergedConfig.propertyBindingTypes); + if (propertyConfig.enabled && attribute.keySpan) { + if (isInSpan(attribute.keySpan.start.offset)) { + if (attribute.type !== BindingType.Property && attribute.type !== BindingType.TwoWay) { + // Skip attr/style/class binding families for property type hints. + super.visitBoundAttribute(attribute); + return; + } + + const symbol = ttc.getSymbolOfNode(attribute, typeCheckInfo.declaration); + if (symbol && symbol.kind === SymbolKind.Input) { + // Component/directive input - check if we should show it + if (propertyConfig.componentInputs) { + const inputSymbol = symbol as InputBindingSymbol; + // Get the first binding's type (usually there's only one) + if (inputSymbol.bindings.length > 0) { + const binding = inputSymbol.bindings[0]; + // Unwrap InputSignal, InputSignalWithTransform, or ModelSignal + const unwrappedType = unwrapAngularType(binding.tsType); + const typeStr = formatType(unwrappedType); + + // Check if this is a required input (for visual indicator) + const isRequired = isRequiredInput(binding.tsSymbol, typeChecker); + const requiredSuffix = getRequiredIndicator( + isRequired, + mergedConfig.requiredInputIndicator, + ); + + // For two-way bindings [(model)], optionally show that it expects WritableSignal + if ( + attribute.type === BindingType.TwoWay && + mergedConfig.twoWayBindingSignalTypes + ) { + // For two-way bindings, show WritableSignal + hints.push( + createTypeHintWithSuffix( + attribute.keySpan.end.offset, + unwrappedType, + `WritableSignal<${typeStr}>`, + requiredSuffix, + ), + ); + } else { + hints.push( + createTypeHintWithSuffix( + attribute.keySpan.end.offset, + unwrappedType, + typeStr, + requiredSuffix, + ), + ); + } + } + } + } else if (symbol && symbol.kind === SymbolKind.DomBinding) { + // DOM property binding - check if we should show it + if (propertyConfig.nativeProperties) { + const domSymbol = symbol as DomBindingSymbol; + const propTypeResult = getDomPropertyType(attribute.name, domSymbol, typeChecker); + if (propTypeResult) { + hints.push( + createTypeHint( + attribute.keySpan.end.offset, + propTypeResult.tsType, + propTypeResult.typeStr, + ), + ); + } + } + } + } + } + + // Show function argument parameter name hints for method calls in bindings + if (mergedConfig.parameterNameHints !== 'none') { + const argVisitor = new FunctionArgumentVisitor( + typeCheckInfo, + ttc, + typeChecker, + tcb, + hints, + isInSpan, + formatType, + mergedConfig, + createTypeHint, + ); + attribute.value.visit(argVisitor); + } + + super.visitBoundAttribute(attribute); + } + + override visitTextAttribute(attribute: TmplAstTextAttribute): void { + // Handle text attribute input bindings: input="stringValue" + // These are inputs bound with plain string literals (no brackets) + const propertyConfig = normalizePropertyConfig(mergedConfig.propertyBindingTypes); + if (propertyConfig.enabled && propertyConfig.componentInputs && attribute.keySpan) { + if (isInSpan(attribute.keySpan.start.offset)) { + const symbol = ttc.getSymbolOfNode(attribute, typeCheckInfo.declaration); + if (symbol && symbol.kind === SymbolKind.Input) { + const inputSymbol = symbol as InputBindingSymbol; + // Get the first binding's type (usually there's only one) + if (inputSymbol.bindings.length > 0) { + const binding = inputSymbol.bindings[0]; + // Unwrap InputSignal, InputSignalWithTransform, or ModelSignal + const unwrappedType = unwrapAngularType(binding.tsType); + const typeStr = formatType(unwrappedType); + + // Check if this is a required input (for visual indicator) + const isRequired = isRequiredInput(binding.tsSymbol, typeChecker); + const requiredSuffix = getRequiredIndicator( + isRequired, + mergedConfig.requiredInputIndicator, + ); + + hints.push( + createTypeHintWithSuffix( + attribute.keySpan.end.offset, + unwrappedType, + typeStr, + requiredSuffix, + ), + ); + } + } + } + } + + super.visitTextAttribute(attribute); + } + + override visitSwitchBlock(block: TmplAstSwitchBlock): void { + // Handle @switch expression type hints: @switch (status: Status) { ... } + if (mergedConfig.switchExpressionTypes && block.expression) { + // Get the expression's source span for positioning + const expr = block.expression; + const sourceSpan = expr instanceof ASTWithSource ? expr.sourceSpan : expr.span; + + if (sourceSpan && isInSpan(sourceSpan.start)) { + // Get the type of the expression from the template type checker + const symbol = ttc.getSymbolOfNode(expr, typeCheckInfo.declaration); + if (symbol && symbol.kind === SymbolKind.Expression) { + const exprSymbol = + symbol as import('@angular/compiler-cli/src/ngtsc/typecheck/api/symbols').ExpressionSymbol; + const typeStr = formatType(exprSymbol.tsType); + hints.push(createTypeHint(sourceSpan.end, exprSymbol.tsType, typeStr)); + } + } + } + + super.visitSwitchBlock(block); + } + + override visitDeferredBlock(block: TmplAstDeferredBlock): void { + // Handle @defer trigger expression type hints: @defer (when isVisible: boolean) { ... } + if (mergedConfig.deferTriggerTypes) { + // Process 'when' trigger if it exists + const whenTrigger = block.triggers.when; + if (whenTrigger && whenTrigger instanceof TmplAstBoundDeferredTrigger) { + const expr = whenTrigger.value; + const sourceSpan = expr instanceof ASTWithSource ? expr.sourceSpan : expr.span; + + if (sourceSpan && isInSpan(sourceSpan.start)) { + const symbol = ttc.getSymbolOfNode(expr, typeCheckInfo.declaration); + if (symbol && symbol.kind === SymbolKind.Expression) { + const exprSymbol = + symbol as import('@angular/compiler-cli/src/ngtsc/typecheck/api/symbols').ExpressionSymbol; + const typeStr = formatType(exprSymbol.tsType); + hints.push(createTypeHint(sourceSpan.end, exprSymbol.tsType, typeStr)); + } + } + } + + // Also check prefetch triggers (prefetch on ...) + const prefetchWhen = block.prefetchTriggers.when; + if (prefetchWhen && prefetchWhen instanceof TmplAstBoundDeferredTrigger) { + const expr = prefetchWhen.value; + const sourceSpan = expr instanceof ASTWithSource ? expr.sourceSpan : expr.span; + + if (sourceSpan && isInSpan(sourceSpan.start)) { + const symbol = ttc.getSymbolOfNode(expr, typeCheckInfo.declaration); + if (symbol && symbol.kind === SymbolKind.Expression) { + const exprSymbol = + symbol as import('@angular/compiler-cli/src/ngtsc/typecheck/api/symbols').ExpressionSymbol; + const typeStr = formatType(exprSymbol.tsType); + hints.push(createTypeHint(sourceSpan.end, exprSymbol.tsType, typeStr)); + } + } + } + + // Also check hydrate triggers (hydrate when ...) + const hydrateWhen = block.hydrateTriggers.when; + if (hydrateWhen && hydrateWhen instanceof TmplAstBoundDeferredTrigger) { + const expr = hydrateWhen.value; + const sourceSpan = expr instanceof ASTWithSource ? expr.sourceSpan : expr.span; + + if (sourceSpan && isInSpan(sourceSpan.start)) { + const symbol = ttc.getSymbolOfNode(expr, typeCheckInfo.declaration); + if (symbol && symbol.kind === SymbolKind.Expression) { + const exprSymbol = + symbol as import('@angular/compiler-cli/src/ngtsc/typecheck/api/symbols').ExpressionSymbol; + const typeStr = formatType(exprSymbol.tsType); + hints.push(createTypeHint(sourceSpan.end, exprSymbol.tsType, typeStr)); + } + } + } + } + + super.visitDeferredBlock(block); + } + } + // Type for the createTypeHint helper function + type CreateTypeHintFn = ( + position: number, + type: ts.Type, + typeStr: string, + paddingLeft?: boolean, + paddingRight?: boolean, + ) => AngularInlayHint; + + // Visitor for pipe expressions in interpolations and bindings + class PipeExpressionVisitor extends RecursiveAstVisitor { + constructor( + private readonly ttc: ReturnType, + private readonly typeChecker: ts.TypeChecker, + private readonly component: ts.ClassDeclaration, + private readonly tcb: ts.Node | null, + private readonly hints: AngularInlayHint[], + private readonly isInSpan: (pos: number) => boolean, + private readonly formatType: (type: ts.Type) => string, + private readonly config: InlayHintsConfig, + private readonly createTypeHintFn: CreateTypeHintFn, + ) { + super(); + } + + /** + * Get the resolved return type of a pipe call from the TCB. + * This uses TypeScript's actual overload resolution, which handles: + * - Overloaded method signatures (like DatePipe) + * - Generic type inference + * - Complex type relationships + */ + private getResolvedPipeReturnType(ast: BindingPipe): ts.Type | null { + if (!this.tcb) return null; + + // Find the call expression in the TCB using the pipe's source span + // The TCB has: _pipe.transform(value, arg1, arg2, ...) + const callExpr = findFirstMatchingNode(this.tcb, { + withSpan: ast.sourceSpan, + filter: ts.isCallExpression, + }); + + if (callExpr) { + // getTypeAtLocation on a call expression returns the resolved return type + // This automatically handles overload resolution and generics! + return this.typeChecker.getTypeAtLocation(callExpr); + } + + return null; + } + + override visitPipe(ast: BindingPipe, context: any): any { + // Pipe hints: {{ value | date : 'short' : 'pl' }} + // Goal: Show parameter names before pipe args and return type after the last argument + // Like function calls: {{ value | date(format: 'short', locale: 'pl'): string | null }} + if (ast.nameSpan && this.isInSpan(ast.nameSpan.start)) { + const symbol = this.ttc.getSymbolOfNode(ast, this.component); + if (symbol && symbol.kind === SymbolKind.Pipe) { + const pipeSymbol = symbol as PipeSymbol; + + // Get the transform method's signature from the pipe class + const transformMethod = pipeSymbol.classSymbol.tsType.getProperty('transform'); + if (transformMethod) { + const transformType = this.typeChecker.getTypeOfSymbolAtLocation( + transformMethod, + transformMethod.valueDeclaration!, + ); + const signatures = this.typeChecker.getSignaturesOfType( + transformType, + ts.SignatureKind.Call, + ); + + if (signatures.length > 0) { + // For parameter name hints, we need to find a signature that matches the argument count + // We use the first signature that has enough parameters + const targetParamCount = ast.args.length + 1; // +1 for the piped value + let signatureForParams = signatures[0]; + for (const sig of signatures) { + const params = sig.getParameters(); + if (params.length >= targetParamCount) { + signatureForParams = sig; + break; + } + } + + const parameters = signatureForParams.getParameters(); + + // Add parameter name hints for pipe arguments + // Parameters[0] is typically "value" (the piped input), so pipe args start at index 1 + if (this.config.parameterNameHints !== 'none') { + for (let i = 0; i < ast.args.length; i++) { + const arg = ast.args[i]; + const param = parameters[i + 1]; // +1 because first param is the piped value + if (!param) continue; + + const paramName = param.getName(); + + // Get the argument's source span + const argSpan = arg.sourceSpan; + if (!argSpan || !this.isInSpan(argSpan.start)) continue; + + // Create interactive hint if enabled + if (this.config.interactiveInlayHints && param.valueDeclaration) { + const paramDecl = param.valueDeclaration; + const sourceFile = paramDecl.getSourceFile(); + this.hints.push({ + position: argSpan.start, + text: '', + kind: 'Parameter', + paddingLeft: false, + paddingRight: true, + displayParts: [ + { + text: paramName, + span: { + start: paramDecl.getStart(), + length: paramDecl.getWidth(), + }, + file: sourceFile.fileName, + }, + {text: ':'}, + ], + }); + } else { + this.hints.push({ + position: argSpan.start, + text: `${paramName}:`, + kind: 'Parameter', + paddingLeft: false, + paddingRight: true, + }); + } + } + } + + // Get the return type using TypeScript's resolved type from the TCB call expression + // This automatically handles overload resolution and generics! + let returnType: ts.Type | null = this.getResolvedPipeReturnType(ast); + + // Fallback: if we couldn't get the resolved type from TCB, + // use the first matching signature's return type + if (!returnType) { + returnType = signatureForParams.getReturnType(); + } + + const returnTypeStr = this.formatType(returnType); + + // Position hint after the last pipe argument, or after pipe name if no args + let hintPosition: number; + if (ast.args.length > 0) { + const lastArg = ast.args[ast.args.length - 1]; + hintPosition = lastArg.sourceSpan.end; + } else { + hintPosition = ast.nameSpan.end; + } + + this.hints.push( + this.createTypeHintFn(hintPosition, returnType, returnTypeStr, false, true), + ); + } + } else { + // Fallback: try to get return type from the TCB directly + const resolvedType = this.getResolvedPipeReturnType(ast); + if (resolvedType) { + const returnTypeStr = this.formatType(resolvedType); + this.hints.push( + this.createTypeHintFn(ast.nameSpan.end, resolvedType, returnTypeStr, false, true), + ); + } else { + // Last resort fallback: use pipeSymbol.tsType's call signatures + const tsType = pipeSymbol.tsType; + const callSignatures = tsType.getCallSignatures(); + if (callSignatures.length > 0) { + const returnType = callSignatures[0].getReturnType(); + const returnTypeStr = this.formatType(returnType); + this.hints.push( + this.createTypeHintFn(ast.nameSpan.end, returnType, returnTypeStr, false, true), + ); + } + } + } + } + } + return super.visitPipe(ast, context); + } + } + + /** + * Visitor for function/method call arguments to show parameter name hints. + * This follows TypeScript's inlay hints implementation for parameter names. + * + * Example: handleClick($event) -> handleClick(event: $event) + * where 'event' is the parameter name from the method signature. + */ + class FunctionArgumentVisitor extends RecursiveAstVisitor { + constructor( + private readonly typeCheckInfo: TypeCheckInfo, + private readonly ttc: ReturnType, + private readonly typeChecker: ts.TypeChecker, + private readonly tcb: ts.Node | null, + private readonly hints: AngularInlayHint[], + private readonly isInSpan: (pos: number) => boolean, + private readonly formatType: (type: ts.Type) => string, + private readonly config: InlayHintsConfig, + private readonly createTypeHintFn?: CreateTypeHintFn, + ) { + super(); + } + + /** + * Get the resolved signature for a function call from the TCB. + * This uses TypeScript's actual overload resolution. + */ + private getResolvedSignature(sourceSpan: { + start: number; + end: number; + }): ts.Signature | undefined { + if (!this.tcb) return undefined; + + // Find the call expression in the TCB + const callExpr = findFirstMatchingNode(this.tcb, { + withSpan: sourceSpan, + filter: ts.isCallExpression, + }); + + if (callExpr) { + // getResolvedSignature returns the actual resolved overload + return this.typeChecker.getResolvedSignature(callExpr); + } + + return undefined; + } + + override visitCall(ast: Call, context: any): any { + this.processCallArguments(ast.receiver, ast.args, ast.sourceSpan); + return super.visitCall(ast, context); + } + + override visitSafeCall(ast: SafeCall, context: any): any { + this.processCallArguments(ast.receiver, ast.args, ast.sourceSpan); + return super.visitSafeCall(ast, context); + } + + private processCallArguments( + receiver: AST, + args: AST[], + sourceSpan: {start: number; end: number}, + ): void { + if (args.length === 0) return; + + // Try to get the resolved signature from the TCB first + // This handles overloads and generics correctly + let signature = this.getResolvedSignature(sourceSpan); + + // Fallback to the first signature from the receiver type + if (!signature) { + const receiverSymbol = this.ttc.getSymbolOfNode(receiver, this.typeCheckInfo.declaration); + if (!receiverSymbol || receiverSymbol.kind !== SymbolKind.Expression) return; + + const exprSymbol = receiverSymbol as ExpressionSymbol; + const receiverType = exprSymbol.tsType; + + // Get call signatures from the receiver type + const signatures = this.typeChecker.getSignaturesOfType( + receiverType, + ts.SignatureKind.Call, + ); + if (signatures.length === 0) return; + + signature = signatures[0]; + } + + const parameters = signature.getParameters(); + + // Check if the last parameter is a rest parameter + let lastParamIsRest = false; + if (parameters.length > 0) { + const lastParam = parameters[parameters.length - 1]; + const lastParamDecl = lastParam.valueDeclaration; + if (lastParamDecl && ts.isParameter(lastParamDecl) && lastParamDecl.dotDotDotToken) { + lastParamIsRest = true; + } + } + + // Track current parameter position (may skip ahead for tuple spreads) + let signatureParamPos = 0; + + // Add parameter name hints for each argument + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + // Handle spread elements - TypeScript provides hints for tuple spreads + if (arg instanceof SpreadElement) { + // Get the type of the spread expression + const spreadSymbol = this.ttc.getSymbolOfNode( + arg.expression, + this.typeCheckInfo.declaration, + ); + if (spreadSymbol && spreadSymbol.kind === SymbolKind.Expression) { + const spreadType = (spreadSymbol as ExpressionSymbol).tsType; + // Check if it's a tuple type + if (this.typeChecker.isTupleType(spreadType)) { + // Get the tuple's fixed length (number of required elements) + const tupleTypeRef = spreadType as ts.TypeReference; + const target = tupleTypeRef.target; + if (target && 'fixedLength' in target) { + const fixedLength = (target as any).fixedLength as number; + if (fixedLength > 0) { + // Advance the parameter position by the tuple's fixed length + signatureParamPos += fixedLength; + } + } + } + } + // Skip showing hint for the spread itself (no visual hint on ...arr) + continue; + } + + // Handle rest parameters: if signatureParamPos >= params.length - 1 and last param is rest, use last param + let param: ts.Symbol | undefined; + let isFirstRestArg = false; + if (signatureParamPos < parameters.length - 1 || !lastParamIsRest) { + // Regular parameter or no rest param + param = parameters[signatureParamPos]; + } else if (lastParamIsRest) { + // This argument maps to the rest parameter + param = parameters[parameters.length - 1]; + // Mark as first rest arg only when this is the first argument mapping to rest + isFirstRestArg = signatureParamPos === parameters.length - 1; + } + + // Advance to next parameter + signatureParamPos++; + + if (!param) continue; + + const paramName = param.getName(); + + // Skip if argument name matches parameter name (like TypeScript does) + // unless parameterNameHintsWhenArgumentMatchesName is true + if ( + !this.config.parameterNameHintsWhenArgumentMatchesName && + this.argumentMatchesParameterName(arg, paramName) + ) { + continue; + } + + // In 'literals' mode, only show hints for literal arguments + if (this.config.parameterNameHints === 'literals' && !this.isHintableLiteral(arg)) { + continue; + } + + // Get the argument's source span + const argSpan = arg.sourceSpan; + if (!argSpan || !this.isInSpan(argSpan.start)) continue; + + // Add "..." prefix for the first rest argument only (like TypeScript does) + const prefix = isFirstRestArg ? '...' : ''; + + // Create interactive hint if enabled + if (this.config.interactiveInlayHints && param.valueDeclaration) { + const paramDecl = param.valueDeclaration; + const sourceFile = paramDecl.getSourceFile(); + this.hints.push({ + position: argSpan.start, + text: '', // Empty for interactive hints + kind: 'Parameter', + paddingLeft: false, + paddingRight: true, + displayParts: [ + { + text: `${prefix}${paramName}`, + span: { + start: paramDecl.getStart(), + length: paramDecl.getWidth(), + }, + file: sourceFile.fileName, + }, + {text: ':'}, + ], + }); + } else { + this.hints.push({ + position: argSpan.start, + text: `${prefix}${paramName}:`, + kind: 'Parameter', + paddingLeft: false, + paddingRight: true, + }); + } + } + } + + /** + * Check if the argument expression matches the parameter name. + * TypeScript skips parameter hints when the argument already conveys the parameter name. + * e.g., handleClick(event) where param is 'event' -> skip hint + */ + private argumentMatchesParameterName(arg: AST, paramName: string): boolean { + if (arg instanceof PropertyRead) { + return arg.name === paramName; + } + return false; + } + + /** + * Check if an argument is a "hintable literal" - used for 'literals' mode. + * TypeScript only shows parameter name hints for literal values in this mode. + * Includes: string/number/boolean literals, null, undefined, NaN, Infinity, + * template literals, arrays, objects + * + * Aligned with TypeScript's isHintableLiteral function. + */ + private isHintableLiteral(arg: AST): boolean { + // LiteralPrimitive covers strings, numbers, booleans, null + if (arg instanceof LiteralPrimitive) { + return true; + } + // LiteralArray covers array literals like [1, 2, 3] + if (arg instanceof LiteralArray) { + return true; + } + // LiteralMap covers object literals like { key: value } + if (arg instanceof LiteralMap) { + return true; + } + // Template literals like `hello ${name}` or `plain string` + if (arg instanceof TemplateLiteral) { + return true; + } + // Unary operations on literals (e.g., -1, +2, -Infinity) + if (arg instanceof Unary) { + if (arg.expr instanceof LiteralPrimitive) { + return true; + } + // -Infinity, +NaN etc. + if (arg.expr instanceof PropertyRead && this.isSpecialNumericIdentifier(arg.expr.name)) { + return true; + } + } + // Special identifiers: undefined, NaN, Infinity (from implicit receiver) + if (arg instanceof PropertyRead && arg.receiver instanceof ImplicitReceiver) { + if (this.isSpecialLiteralIdentifier(arg.name)) { + return true; + } + } + return false; + } + + /** + * Check if an identifier name is a special literal-like value. + * TypeScript treats undefined, NaN, and Infinity as literals for hint purposes. + */ + private isSpecialLiteralIdentifier(name: string): boolean { + return name === 'undefined' || name === 'NaN' || name === 'Infinity'; + } + + /** + * Check if an identifier name is a special numeric value (NaN, Infinity). + * Used for unary expressions like -Infinity. + */ + private isSpecialNumericIdentifier(name: string): boolean { + return name === 'NaN' || name === 'Infinity'; + } + + /** + * Visit arrow functions in template expressions. + * Provides: + * 1. Parameter type hints - like TS includeInlayFunctionParameterTypeHints + * 2. Return type hints - like TS includeInlayFunctionLikeReturnTypeHints + * + * Example: `(a, b) => a + b` becomes `(a: number, b: number): number => a + b` + */ + override visitArrowFunction(ast: ArrowFunction, context: any): any { + // Get the arrow function type from TCB + if (!this.tcb) { + return super.visitArrowFunction(ast, context); + } + + // Find the arrow function expression in the TCB + const arrowExpr = findFirstMatchingNode(this.tcb, { + withSpan: ast.sourceSpan, + filter: ts.isArrowFunction, + }); + + if (!arrowExpr) { + return super.visitArrowFunction(ast, context); + } + + const arrowType = this.typeChecker.getTypeAtLocation(arrowExpr); + const callSignatures = arrowType.getCallSignatures(); + if (callSignatures.length === 0) { + return super.visitArrowFunction(ast, context); + } + + const signature = callSignatures[0]; + const parameters = signature.getParameters(); + + // Parameter type hints + if (this.config.arrowFunctionParameterTypes) { + for (let i = 0; i < ast.parameters.length && i < parameters.length; i++) { + const param = ast.parameters[i]; + const tsParam = parameters[i]; + + // Get the parameter's source span + if (!param.sourceSpan || !this.isInSpan(param.sourceSpan.start)) continue; + + // Get parameter type from TypeScript + const paramType = this.typeChecker.getTypeOfSymbolAtLocation(tsParam, arrowExpr); + const paramTypeStr = this.formatType(paramType); + + // Skip if type is 'any' or parameter name matches type + if (paramTypeStr === 'any') continue; + if (param.name.toLowerCase() === paramTypeStr.toLowerCase()) continue; + + // Position hint after the parameter name - use createTypeHint if available + if (this.createTypeHintFn) { + this.hints.push(this.createTypeHintFn(param.sourceSpan.end, paramType, paramTypeStr)); + } else { + this.hints.push({ + position: param.sourceSpan.end, + text: `: ${paramTypeStr}`, + kind: 'Type', + paddingLeft: false, + paddingRight: false, + }); + } + } + } + + // Return type hint + if (this.config.arrowFunctionReturnTypes) { + const returnType = signature.getReturnType(); + const returnTypeStr = this.formatType(returnType); + + // Skip if return type is 'any' or 'void' + if (returnTypeStr !== 'any' && returnTypeStr !== 'void') { + // Put the return hint just before the body expression. + // This renders close to the arrow and avoids overlapping parameter hints. + const hintPosition = ast.body.sourceSpan.start; + if (this.isInSpan(hintPosition)) { + if (this.createTypeHintFn) { + this.hints.push(this.createTypeHintFn(hintPosition, returnType, returnTypeStr)); + } else { + this.hints.push({ + position: hintPosition, + text: `: ${returnTypeStr}`, + kind: 'Type', + paddingLeft: false, + paddingRight: false, + }); + } + } + } + } + + // Continue visiting the body + return super.visitArrowFunction(ast, context); + } + } + + /** + * Unwraps Angular wrapper types to get the underlying value type. + * Handles: EventEmitter, OutputEmitterRef, Observable, Subject, + * InputSignal, InputSignalWithTransform, ModelSignal + * + * @param type The type to unwrap + * @param typeChecker TypeScript type checker + * @returns The unwrapped type, or the original type if not a wrapper + */ + function unwrapAngularType(type: ts.Type): ts.Type { + const typeRef = type as ts.TypeReference; + const typeArgs = typeRef.typeArguments ?? typeRef.aliasTypeArguments; + if (!typeArgs || typeArgs.length === 0) { + return type; + } + const symbolName = type.symbol?.name ?? type.aliasSymbol?.name; + if ( + symbolName === 'EventEmitter' || + symbolName === 'OutputEmitterRef' || + symbolName === 'Observable' || + symbolName === 'Subject' || + symbolName === 'InputSignal' || + symbolName === 'ModelSignal' || + symbolName === 'InputSignalWithTransform' + ) { + return typeArgs[0]; + } + return type; + } + + /** + * Check if an input is required (input.required() or @Input with required: true). + * + * For signal inputs, we check the type signature: + * - Required: InputSignal (no undefined in the type) + * - Optional: InputSignal + * + * For decorator inputs, we check @Input({required: true}). + */ + function isRequiredInput(symbol: ts.Symbol | undefined, typeChecker: ts.TypeChecker): boolean { + if (!symbol) return false; + + const declarations = symbol.getDeclarations(); + if (!declarations || declarations.length === 0) return false; + + const decl = declarations[0]; + if (!ts.isPropertyDeclaration(decl) && !ts.isPropertySignature(decl)) return false; + + // Get the type at the declaration + const type = typeChecker.getTypeAtLocation(decl); + const typeName = typeChecker.typeToString(type); + + // Check for signal-based inputs via type signature + // Required inputs have InputSignal where T doesn't include undefined + // Optional inputs have InputSignal + if (typeName.includes('InputSignal<')) { + // If the inner type includes undefined, it's optional + if (typeName.includes('undefined')) { + return false; + } + // Otherwise check if it's actually using input.required() via AST + // This handles edge cases where the type might look required but isn't + if (ts.isPropertyDeclaration(decl) && decl.initializer) { + if (ts.isCallExpression(decl.initializer)) { + const callExpr = decl.initializer; + if (ts.isPropertyAccessExpression(callExpr.expression)) { + const methodName = callExpr.expression.name.text; + if (methodName === 'required') { + return true; + } + } + } + } + // Even without AST check, if type doesn't have undefined, treat as required + return true; + } + + // Check for decorator-based @Input({required: true}) + if (ts.isPropertyDeclaration(decl)) { + const decorators = ts.getDecorators(decl); + if (decorators) { + for (const decorator of decorators) { + if (ts.isCallExpression(decorator.expression)) { + const decoratorExpr = decorator.expression.expression; + // Handle both `Input` and `namespace.Input` patterns + const decoratorName = ts.isIdentifier(decoratorExpr) + ? decoratorExpr.text + : ts.isPropertyAccessExpression(decoratorExpr) + ? decoratorExpr.name.text + : null; + + if (decoratorName === 'Input') { + // Check if there's an options object with required: true + const args = decorator.expression.arguments; + if (args.length > 0) { + const firstArg = args[0]; + if (ts.isObjectLiteralExpression(firstArg)) { + for (const prop of firstArg.properties) { + if (ts.isPropertyAssignment(prop)) { + const propName = ts.isIdentifier(prop.name) + ? prop.name.text + : ts.isStringLiteral(prop.name) + ? prop.name.text + : null; + if ( + propName === 'required' && + prop.initializer.kind === ts.SyntaxKind.TrueKeyword + ) { + return true; + } + } + } + } + } + } + } + } + } + } + + return false; + } + + /** + * Get the visual indicator suffix for required inputs. + */ + function getRequiredIndicator( + isRequired: boolean, + indicator: 'none' | 'asterisk' | 'exclamation' | undefined, + ): string { + if (!isRequired || !indicator || indicator === 'none') { + return ''; + } + if (indicator === 'asterisk') { + return '*'; + } + if (indicator === 'exclamation') { + return '!'; + } + return ''; + } + + /** + * Result type for getEventType - contains both the type string and optionally the ts.Type + * for interactive hint support. + */ + interface EventTypeResult { + typeStr: string; + tsType: ts.Type | null; + } + + /** + * Get the $event type for an event binding from TCB type inference. + * Returns null if the type cannot be determined. + * + * Handles: + * - @Output() EventEmitter + * - output() OutputEmitterRef + * - model() ModelSignal - generates (nameChange) event + * - DOM events (click, input, etc.) + */ + function getEventType( + event: TmplAstBoundEvent, + typeCheckInfo: TypeCheckInfo, + ttc: ReturnType, + typeChecker: ts.TypeChecker, + ): EventTypeResult | null { + // Get the event's output binding type from the template type checker + const symbol = ttc.getSymbolOfNode(event, typeCheckInfo.declaration); + if (symbol && symbol.kind === SymbolKind.Output) { + // Component/directive output event (@Output, output(), or model() change event) + const outputSymbol = symbol as OutputBindingSymbol; + const isDirectiveOrComponentOutput = outputSymbol.bindings.some( + (binding) => binding.target.kind === SymbolKind.Directive, + ); + if (outputSymbol.bindings.length > 0) { + const bindingType = outputSymbol.bindings[0].tsType; + const resolvedType = isDirectiveOrComponentOutput + ? unwrapAngularType(bindingType) + : bindingType; + return { + typeStr: typeChecker.typeToString(resolvedType), + tsType: resolvedType, + }; + } + } + + // Animation events (check before DOM events) + if (event.type === ParsedEventType.LegacyAnimation) { + // Try to get AnimationEvent type from lib.dom + const animationEventType = getGlobalDomType('AnimationEvent', typeChecker); + return { + typeStr: 'AnimationEvent', + tsType: animationEventType, + }; + } + if (event.type === ParsedEventType.Animation) { + // AnimationCallbackEvent is an Angular-specific type, no DOM equivalent + return { + typeStr: 'AnimationCallbackEvent', + tsType: null, + }; + } + + const eventTypeFromHandler = getEventTypeFromHandler(event, typeCheckInfo, ttc, typeChecker); + if (eventTypeFromHandler) { + return eventTypeFromHandler; + } + + return null; + } + + /** + * Try to get a global DOM type (like MouseEvent, KeyboardEvent) from the type checker. + * Returns null if the type is not found in the global scope. + */ + function getGlobalDomType(typeName: string, typeChecker: ts.TypeChecker): ts.Type | null { + let globalDomTypeCache = globalDomTypeCacheByChecker.get(typeChecker); + if (!globalDomTypeCache) { + globalDomTypeCache = new Map(); + globalDomTypeCacheByChecker.set(typeChecker, globalDomTypeCache); + } + + const cached = globalDomTypeCache.get(typeName); + if (cached !== undefined) { + return cached; + } + + try { + // Try to resolve the type from the global scope + const symbol = typeChecker.resolveName( + typeName, + undefined, + ts.SymbolFlags.Type, + /* excludeGlobals */ false, + ); + if (symbol) { + const declarations = symbol.getDeclarations(); + if (declarations && declarations.length > 0) { + const resolvedType = typeChecker.getDeclaredTypeOfSymbol(symbol); + globalDomTypeCache.set(typeName, resolvedType); + return resolvedType; + } + } + } catch { + // Type not found in global scope + } + globalDomTypeCache.set(typeName, null); + return null; + } + + /** + * Get the event type by finding the $event reference in the handler + * and using its inferred type from the TCB. + */ + function getEventTypeFromHandler( + event: TmplAstBoundEvent, + typeCheckInfo: TypeCheckInfo, + ttc: ReturnType, + typeChecker: ts.TypeChecker, + ): EventTypeResult | null { + let eventResult: EventTypeResult | null = null; + + class EventTypeVisitor extends RecursiveAstVisitor { + override visitPropertyRead(ast: PropertyRead, context: any): any { + if (ast.name === '$event' && !(ast.receiver instanceof PropertyRead)) { + const symbol = ttc.getSymbolOfNode(ast, typeCheckInfo.declaration); + if (symbol && symbol.kind === SymbolKind.Expression) { + const exprSymbol = symbol as ExpressionSymbol; + eventResult = { + typeStr: typeChecker.typeToString(exprSymbol.tsType), + tsType: exprSymbol.tsType, + }; + } + } + return super.visitPropertyRead(ast, context); + } + } + + const visitor = new EventTypeVisitor(); + event.handler.visit(visitor); + return eventResult; + } + + // Visit the template (if it exists - directives without templates will skip this) + if (template) { + const visitor = new InlayHintVisitor(); + tmplAstVisitAll(visitor, template); + } + + // Also process host element bindings (from component/directive host property and @HostBinding/@HostListener) + // NOTE: Host bindings have keySpan positions in the TypeScript file (not template), so we use + // a separate isInSpan check that accounts for the host binding positions being in TS source. + // When the span parameter covers only part of the file (e.g., visible area), host binding + // positions might be outside that range, but they should still be shown since they belong + // to this component. We create a helper function that checks if we're requesting the whole file. + const hostElement = ttc.getHostElement(typeCheckInfo.declaration); + if (hostElement) { + // For host bindings, check if the position is within the requested span. + // Host binding keySpan positions are absolute TypeScript file positions. + const isHostBindingInSpan = (pos: number): boolean => { + // If pos < 0, it's a dummy span, skip it + if (pos < 0) return false; + // Always show host binding hints (they're conceptually part of the component) + // The keySpan positions are in TypeScript source, but the span might only cover + // part of the file. Since host bindings are always associated with the component, + // we should show them regardless of whether they're in the visible range. + return pos >= span.start && pos < span.start + span.length; + }; + + // Process host property bindings for property type hints + // These come from @HostBinding('prop') or host: { '[prop]': 'expr' } + const propertyConfig = normalizePropertyConfig(mergedConfig.propertyBindingTypes); + if (propertyConfig.enabled && propertyConfig.nativeProperties) { + for (const binding of hostElement.bindings) { + // Only process bindings with valid keySpan (not dummy spans) + if (binding.keySpan && binding.keySpan.start.offset >= 0) { + if (isHostBindingInSpan(binding.keySpan.start.offset)) { + const rawHostBindingKey = typeCheckInfo.declaration + .getSourceFile() + .text.slice(binding.keySpan.start.offset, binding.keySpan.end.offset) + .replace(/["'`]/g, '') + .trim(); + + if (/^\[(attr\.|style\.|class\.)/i.test(rawHostBindingKey)) { + continue; + } + + const symbol = ttc.getSymbolOfNode(binding, typeCheckInfo.declaration); + if (symbol && symbol.kind === SymbolKind.DomBinding) { + const domSymbol = symbol as DomBindingSymbol; + const propTypeResult = getDomPropertyType(binding.name, domSymbol, typeChecker); + if (propTypeResult) { + hints.push( + createTypeHint( + binding.keySpan.end.offset, + propTypeResult.tsType, + propTypeResult.typeStr, + ), + ); + } + } + } + } + } + } + + // Process host event listeners for event type hints and function argument hints + const eventConfig = normalizeEventConfig(mergedConfig.eventParameterTypes); + for (const listener of hostElement.listeners) { + // Event type hint on the event name + if (eventConfig.enabled && listener.keySpan) { + if (isHostBindingInSpan(listener.keySpan.start.offset)) { + // For host listeners, determine the event source + const symbol = ttc.getSymbolOfNode(listener, typeCheckInfo.declaration); + const isComponentEvent = symbol?.kind === SymbolKind.Output; + const isAnimationEvent = + listener.type === ParsedEventType.LegacyAnimation || + listener.type === ParsedEventType.Animation; + const isNativeEvent = !isComponentEvent && !isAnimationEvent; + + const shouldShow = + (isNativeEvent && eventConfig.nativeEvents) || + (isComponentEvent && eventConfig.componentEvents) || + (isAnimationEvent && eventConfig.animationEvents); + + if (shouldShow) { + const eventTypeResult = getEventType(listener, typeCheckInfo, ttc, typeChecker); + if (eventTypeResult) { + // Use createTypeHint for interactive hints if we have the ts.Type + if (eventTypeResult.tsType) { + hints.push( + createTypeHint( + listener.keySpan.end.offset, + eventTypeResult.tsType, + eventTypeResult.typeStr, + ), + ); + } else { + // Fallback for types without ts.Type + hints.push({ + position: listener.keySpan.end.offset, + text: `: ${eventTypeResult.typeStr}`, + kind: 'Type', + paddingLeft: false, + paddingRight: false, + }); + } + } + } + } + } + + // Function argument parameter name hints + if (mergedConfig.parameterNameHints !== 'none') { + const argVisitor = new FunctionArgumentVisitor( + typeCheckInfo, + ttc, + typeChecker, + tcb, + hints, + isHostBindingInSpan, + formatType, + mergedConfig, + createTypeHint, + ); + listener.handler.visit(argVisitor); + } + + // @HostListener argument type hints + // For @HostListener('click', ['$event.target', '$event.clientX']), + // we show the inferred type for each expression argument + if (mergedConfig.hostListenerArgumentTypes) { + processHostListenerArgumentTypes( + listener, + ttc, + typeChecker, + tcb, + typeCheckInfo, + hints, + isHostBindingInSpan, + formatType, + createTypeHint, + ); + } + } + } + + const dedupedHints: AngularInlayHint[] = []; + const seen = new Set(); + for (const hint of hints) { + const label = hint.displayParts?.map((part) => part.text).join('') ?? hint.text; + const key = `${hint.position}|${hint.kind}|${label}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + dedupedHints.push(hint); + } + + return dedupedHints; +} + +/** + * Process @HostListener argument expressions and add type hints. + * + * For decorators like `@HostListener('click', ['$event.target', '$event.clientX'])`, + * this shows the inferred type for each expression argument: + * `['$event.target: EventTarget | null', '$event.clientX: number']` + * + * The handler is a Call node where: + * - receiver is a PropertyRead of the method name + * - args are the parsed expressions from the decorator arguments + */ +function processHostListenerArgumentTypes( + listener: TmplAstBoundEvent, + ttc: TemplateTypeChecker, + typeChecker: ts.TypeChecker, + tcb: ts.Node | null, + typeCheckInfo: TypeCheckInfo, + hints: AngularInlayHint[], + isInSpan: (offset: number) => boolean, + formatType: (type: ts.Type) => string, + createTypeHintFn: ( + position: number, + type: ts.Type, + typeStr: string, + paddingLeft?: boolean, + paddingRight?: boolean, + ) => AngularInlayHint, +): void { + // The handler should be a Call node for @HostListener + if (!(listener.handler instanceof Call)) { + return; + } + + const handlerCall = listener.handler as Call; + + // Skip if no arguments or if the handler doesn't have the expected structure + if (handlerCall.args.length === 0) { + return; + } + + // Process each argument expression + for (const arg of handlerCall.args) { + // Skip simple identifiers that are just variable references (method parameters) + // We only want to show hints for expressions like $event.target + if (arg instanceof PropertyRead && arg.receiver instanceof ImplicitReceiver) { + // This is a simple variable reference, might be $event itself + // Only skip if it's NOT $event (which would have type information) + if (arg.name !== '$event') { + continue; + } + } + + // Get the source span for positioning the hint + const argSpan = arg.sourceSpan; + if (!argSpan || !isInSpan(argSpan.start)) { + continue; + } + + // Try to find the corresponding TCB node and get its type + let hintType: ts.Type | null = null; + + if (tcb) { + const tcbNode = findFirstMatchingNode(tcb, { + withSpan: argSpan, + filter: (node): node is ts.Expression => ts.isExpression(node), + }); + + if (tcbNode) { + hintType = typeChecker.getTypeAtLocation(tcbNode); + } + } + + if (hintType) { + const typeStr = formatType(hintType); + hints.push(createTypeHintFn(argSpan.end, hintType, typeStr)); + } + } +} +/** + * Get the type of a DOM property binding. + * + * For bindings like [value], [disabled], etc., looks up the property type + * on the element's DOM interface using TypeScript's type checker. + * + * Note: Style bindings ([style.width], [style.width.px]), class bindings ([class.active]), + * and attribute bindings ([attr.data-cy]) are NOT type-checked by Angular currently. + * See: packages/compiler-cli/src/ngtsc/typecheck/src/ops/inputs.ts + * "TODO: properly check class and style bindings." + * + * We intentionally don't show inlay hints for these bindings until Angular + * implements proper type checking for them, as any hint would be misleading + * (e.g., [style.width.px]="'2'" is valid at runtime). + */ +interface DomPropertyTypeResult { + typeStr: string; + tsType: ts.Type; +} + +function getDomPropertyType( + bindingName: string, + domSymbol: DomBindingSymbol, + typeChecker: ts.TypeChecker, +): DomPropertyTypeResult | null { + // Skip style/class/attr bindings - Angular doesn't type-check these yet + // See: packages/compiler-cli/src/ngtsc/typecheck/src/ops/inputs.ts + // "TODO: properly check class and style bindings." + if ( + bindingName.startsWith('style.') || + bindingName.startsWith('class.') || + bindingName.startsWith('attr.') + ) { + return null; + } + + // For regular DOM properties, look up the type on the element + if (domSymbol.host.kind === SymbolKind.Element) { + const elementSymbol = domSymbol.host as ElementSymbol; + const elementType = elementSymbol.tsType; + + // Get the property from the element type + const property = elementType.getProperty(bindingName); + if (property) { + const propType = typeChecker.getTypeOfSymbol(property); + return { + typeStr: typeChecker.typeToString(propType), + tsType: propType, + }; + } + } + + return null; +} diff --git a/packages/language-service/src/language_service.ts b/packages/language-service/src/language_service.ts index b06d00bc9ed..c93dc46d1a1 100644 --- a/packages/language-service/src/language_service.ts +++ b/packages/language-service/src/language_service.ts @@ -24,7 +24,9 @@ import ts from 'typescript'; import { ApplyRefactoringProgressFn, ApplyRefactoringResult, + AngularInlayHint, GetComponentLocationsForTemplateResponse, + InlayHintsConfig, GetTcbResponse, GetTemplateLocationForComponentResponse, LinkedEditingRanges, @@ -57,6 +59,7 @@ import {getTypeCheckInfoAtPosition, isTypeScriptFile, TypeCheckInfo} from './uti import {ActiveRefactoring, allRefactorings} from './refactorings/refactoring'; import {getClassificationsForTemplate, TokenEncodingConsts} from './semantic_tokens'; import {isExternalResource} from '@angular/compiler-cli/src/ngtsc/metadata'; +import {getInlayHintsForTemplate} from './inlay_hints'; type LanguageServiceConfig = Omit; @@ -216,6 +219,85 @@ export class LanguageService { }); } + /** + * Provide Angular-specific inlay hints for templates. + * + * This returns hints for: + * - @for loop variable types: `@for (user: User of users)` + * - @if alias types: `@if (data; as result: ApiResult)` + * - Event parameter types: `(click)="onClick($event: MouseEvent)"` + * - Pipe output types: `{{ value | async: Observable }}` + * - @let declaration types + * + * @param fileName The file to get inlay hints for + * @param span The text span to get hints within + * @param config Optional configuration for which hints to show + */ + provideInlayHints( + fileName: string, + span: ts.TextSpan, + config?: InlayHintsConfig, + ): AngularInlayHint[] { + // Use LsQuickInfo phase since inlay hints are similar in cost + return ( + this.withCompilerAndPerfTracing(PerfPhase.LsQuickInfo, (compiler) => { + const hints: AngularInlayHint[] = []; + + if (isTypeScriptFile(fileName)) { + // For TypeScript files, find all components and process their templates + const program = compiler.getCurrentProgram(); + const sourceFile = program.getSourceFile(fileName); + if (!sourceFile) { + return hints; + } + + const ttc = compiler.getTemplateTypeChecker(); + + // Walk the source file to find component/directive classes + const visit = (node: ts.Node): void => { + if (ts.isClassDeclaration(node) && node.name) { + // Try to get the template for this class (component) or host element (directive) + try { + const template = ttc.getTemplate(node); + const hostElement = ttc.getHostElement(node); + + // Process if we have either a template or host element + if (template || hostElement) { + // This is a component with a template or a directive with host bindings + const typeCheckInfo: TypeCheckInfo = { + declaration: node, + nodes: template ?? [], + }; + const templateHints = getInlayHintsForTemplate( + compiler, + typeCheckInfo, + span, + config, + ); + hints.push(...templateHints); + } + } catch { + // Not a component/directive or error getting template, skip + } + } + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + } else { + // For external template files (HTML), find the associated component + const typeCheckInfo = getTypeCheckInfoAtPosition(fileName, span.start, compiler); + if (typeCheckInfo) { + const templateHints = getInlayHintsForTemplate(compiler, typeCheckInfo, span, config); + hints.push(...templateHints); + } + } + + return hints; + }) ?? [] + ); + } + private getQuickInfoAtPositionImpl( fileName: string, position: number, diff --git a/packages/language-service/src/ts_plugin.ts b/packages/language-service/src/ts_plugin.ts index 6729856d0ee..b9200db03de 100644 --- a/packages/language-service/src/ts_plugin.ts +++ b/packages/language-service/src/ts_plugin.ts @@ -17,6 +17,8 @@ import { isNgLanguageService, LinkedEditingRanges, NgLanguageService, + AngularInlayHint, + InlayHintsConfig, } from '../api'; import {LanguageService} from './language_service'; @@ -350,6 +352,14 @@ export function create(info: ts.server.PluginCreateInfo): NgLanguageService { ngLS.ensureProjectAnalyzed(); } + function getAngularInlayHints( + fileName: string, + span: ts.TextSpan, + config?: InlayHintsConfig, + ): AngularInlayHint[] { + return ngLS.provideInlayHints(fileName, span, config); + } + return { ...tsLS, ensureProjectAnalyzed, @@ -382,6 +392,7 @@ export function create(info: ts.server.PluginCreateInfo): NgLanguageService { getApplicableRefactors, applyRefactoring, getLinkedEditingRangeAtPosition, + getAngularInlayHints, }; } diff --git a/vscode-ng-language-service/README.md b/vscode-ng-language-service/README.md index 6c971ab68d8..e2902d4e422 100644 --- a/vscode-ng-language-service/README.md +++ b/vscode-ng-language-service/README.md @@ -67,3 +67,57 @@ code --install-extension /path/to/ngls.vsix - [nvim-lspconfig](https://github.com/neovim/nvim-lspconfig/blob/master/doc/configs.md#angularls) for [Neovim](https://neovim.io) - [Wild Web Developer](https://github.com/eclipse/wildwebdeveloper) for Eclipse - [lsp-mode](https://github.com/emacs-lsp/lsp-mode) for Emacs + +## Inlay Hints + +The Angular Language Service provides inlay hints for templates, showing inline type annotations directly in your code. This feature helps you understand types without hovering over variables. + +### Examples + +```html + +@for (user /* : User */ of users; track user.id) { {{ user.name }} } + + +@if (currentUser; as user) { {{ user.name }} } +@if (currentUser.profile; as profile /* : Profile */) { {{ profile.name }} } + + +@let count /* : number */ = items.length; + + + + + + +``` + +### Configuration + +All inlay hints settings are under `angular.inlayHints.*`: + +| Setting | Default | Description | +| --------------------------------- | -------- | ----------------------------------------------------------------------------- | +| `eventParameterTypes` | `false` | Show `$event` types for event bindings | +| `forLoopVariableTypes` | `false` | Show types for `@for` loop variables | +| `ifAliasTypes` | `false` | Show types for `@if` aliases. Set to `'complex'` for complex expressions only | +| `letDeclarationTypes` | `false` | Show types for `@let` declarations | +| `referenceVariableTypes` | `false` | Show types for template reference variables | +| `suppressWhenTypeMatchesName` | `false` | Suppress variable type hints when variable name matches type | +| `arrowFunctionParameterTypes` | `false` | Show parameter types for arrow functions | +| `arrowFunctionReturnTypes` | `false` | Show return types for arrow functions | +| `parameterNameHints` | `'all'` | Show parameter names: `'none'`, `'literals'`, or `'all'` | +| `suppressWhenArgumentMatchesName` | `true` | Suppress parameter hints when argument matches parameter name | +| `propertyBindingTypes` | `false` | Show types for property/input bindings | +| `pipeOutputTypes` | `false` | Show pipe output types | +| `twoWayBindingSignalTypes` | `false` | Show signal types for two-way bindings | +| `requiredInputIndicator` | `'none'` | Required input indicator: `'none'`, `'asterisk'`, `'exclamation'` | +| `interactiveInlayHints` | `false` | Enable click-to-navigate type definitions | +| `hostListenerArgumentTypes` | `false` | Show `@HostListener` argument types | +| `switchExpressionTypes` | `false` | Show `@switch` expression types | +| `deferTriggerTypes` | `false` | Show `@defer` trigger types | + +To disable all Angular inlay hints, you can set `editor.inlayHints.enabled` to `"off"` in your VS Code settings. diff --git a/vscode-ng-language-service/client/src/client.ts b/vscode-ng-language-service/client/src/client.ts index 08d66854f64..cea70dfc6a4 100644 --- a/vscode-ng-language-service/client/src/client.ts +++ b/vscode-ng-language-service/client/src/client.ts @@ -26,7 +26,11 @@ import { } from '../../common/requests'; import {NodeModule, resolve, Version} from '../../common/resolver'; -import {isInsideStringLiteral, isNotTypescriptOrSupportedDecoratorField} from './embedded_support'; +import { + isInsideStringLiteral, + isNotTypescriptOrSupportedDecoratorField, + isNotTypescriptOrSupportedDecoratorRange, +} from './embedded_support'; interface GetTcbResponse { uri: vscode.Uri; @@ -85,6 +89,7 @@ export class AngularLanguageClient implements vscode.Disposable { {scheme: 'file', language: 'typescript'}, ], synchronize: { + configurationSection: ['angular', 'typescript', 'editor.inlayHints'], fileEvents, }, // Don't let our output console pop open @@ -263,6 +268,26 @@ export class AngularLanguageClient implements vscode.Disposable { return next(document, position, token); } }, + provideInlayHints: async ( + document: vscode.TextDocument, + range: vscode.Range, + token: vscode.CancellationToken, + next, + ) => { + if (!(await this.isInAngularProject(document))) { + return null; + } + + // For TypeScript files, only request Angular inlay hints when the + // visible range intersects supported decorator fields (e.g. inline + // template/style metadata). This matches the guarding strategy used + // by other Angular LSP features and avoids work on unsupported TS regions. + if (!isNotTypescriptOrSupportedDecoratorRange(document, range)) { + return null; + } + + return next(document, range, token); + }, }, }; } diff --git a/vscode-ng-language-service/client/src/embedded_support.ts b/vscode-ng-language-service/client/src/embedded_support.ts index 1d4155f2644..aacf868b4f7 100644 --- a/vscode-ng-language-service/client/src/embedded_support.ts +++ b/vscode-ng-language-service/client/src/embedded_support.ts @@ -34,6 +34,25 @@ export function isNotTypescriptOrSupportedDecoratorField( ); } +/** + * Determines if a range is eligible for Angular language-service requests. + * + * For TypeScript files this is true when either range endpoint is within a + * supported decorator field. Non-TypeScript documents are always eligible. + */ +export function isNotTypescriptOrSupportedDecoratorRange( + document: vscode.TextDocument, + range: vscode.Range, +): boolean { + if (document.languageId !== 'typescript') { + return true; + } + return ( + isNotTypescriptOrSupportedDecoratorField(document, range.start) || + isNotTypescriptOrSupportedDecoratorField(document, range.end) + ); +} + /** * Determines if the position is inside a string literal. Returns `true` if the document language is * not TypeScript. diff --git a/vscode-ng-language-service/package.json b/vscode-ng-language-service/package.json index 95813164eeb..b0fe805f4db 100644 --- a/vscode-ng-language-service/package.json +++ b/vscode-ng-language-service/package.json @@ -161,6 +161,119 @@ "markdownDescription": "When enabled, the Angular Language Service will delegate file watching to VS Code instead of creating its own internal file watchers. This can significantly improve performance (greater than 10x faster initialization) and reduce resource usage in large repositories." } } + }, + { + "title": "Inlay Hints", + "properties": { + "angular.inlayHints.bindingHints.pipeOutputTypes": { + "type": "boolean", + "default": false, + "markdownDescription": "Show type hints for pipe output types.\n\nExample:\n\n```html\n{{ value | async /* : User | null */ }}\n```" + }, + "angular.inlayHints.bindingHints.propertyBindingTypes": { + "type": "boolean", + "default": false, + "markdownDescription": "Show type hints for property/input bindings.\n\nExample:\n\n```html\n\n```" + }, + "angular.inlayHints.bindingHints.requiredInputIndicator": { + "type": "string", + "enum": [ + "none", + "asterisk", + "exclamation" + ], + "default": "none", + "markdownDescription": "Visual indicator for required inputs. `'asterisk'` shows `*`, `'exclamation'` shows `!` after the type.\n\nExample:\n\n```html\n\n```" + }, + "angular.inlayHints.bindingHints.twoWayBindingSignalTypes": { + "type": "boolean", + "default": false, + "markdownDescription": "Show signal type hints for two-way bindings.\n\nExample:\n\n```html\n */)]=\"model\" />\n```" + }, + "angular.inlayHints.controlFlowHints.deferTriggerTypes": { + "type": "boolean", + "default": false, + "markdownDescription": "Show type hints for `@defer` `when` trigger expressions. The expression is evaluated by truthiness, so its type is not always `boolean`.\n\nExample:\n\n```html\n@defer (when user /* : User | null */) { ... }\n```" + }, + "angular.inlayHints.controlFlowHints.switchExpressionTypes": { + "type": "boolean", + "default": false, + "markdownDescription": "Show type hints for `@switch` expression types.\n\nExample:\n\n```html\n@switch (status /* : Status */) { ... }\n```" + }, + "angular.inlayHints.eventHints.hostListenerArgumentTypes": { + "type": "boolean", + "default": false, + "markdownDescription": "Show type hints for host listener handler arguments.\n\nExamples:\n\n```ts\n@HostListener('keydown', ['$event', '$event.target'])\nonHostKey(event /* : KeyboardEvent */, target /* : EventTarget | null */) {}\n```\n\n```ts\nhost: {\n '(keydown)': 'onHostKey($event /* : KeyboardEvent */, $event.target /* : EventTarget | null */)',\n}\n```" + }, + "angular.inlayHints.eventHints.parameterTypes": { + "type": "boolean", + "default": false, + "markdownDescription": "Show type hints for event binding `$event` parameter.\n\nExample:\n\n```html\n\n```" + }, + "angular.inlayHints.functionTypes.arrowFunctionParameterTypes": { + "type": "boolean", + "default": false, + "markdownDescription": "Show type hints for arrow function parameters in templates.\n\nExample:\n\n```html\n{{ value | apply : (x /* : number */) => x * 2 }}\n```" + }, + "angular.inlayHints.functionTypes.arrowFunctionReturnTypes": { + "type": "boolean", + "default": false, + "markdownDescription": "Show return type hints for arrow functions in templates.\n\nExample:\n\n```html\n{{ value | apply : (x) /* : number */ => x * 2 }}\n```" + }, + "angular.inlayHints.interaction.interactiveInlayHints": { + "type": "boolean", + "default": false, + "markdownDescription": "Enable interactive inlay hints that navigate to definitions when clicked.\n\nExample:\n\n```html\n{{ formatAmount(/* precision: */ 2) }}\n```\n\nWith this enabled, hint parts can be navigated." + }, + "angular.inlayHints.parameterHints.nameHints": { + "type": "string", + "enum": [ + "none", + "literals", + "all" + ], + "default": "all", + "markdownDescription": "Show parameter name hints for function/method calls. `'literals'` shows hints only for literal arguments.\n\nExample:\n\n```html\n{{ formatAmount(/* value: */ amount, /* precision: */ 2) }}\n```" + }, + "angular.inlayHints.variableTypes.forLoopVariableTypes": { + "type": "boolean", + "default": false, + "markdownDescription": "Show type hints for `@for` loop variables.\n\nExample:\n\n```html\n@for (user /* : User */ of users) { ... }\n```" + }, + "angular.inlayHints.variableTypes.ifAliasTypes": { + "type": [ + "boolean", + "string" + ], + "enum": [ + true, + false, + "complex" + ], + "default": false, + "markdownDescription": "Show type hints for `@if` alias variables. Set to `'complex'` to only show hints for complex expressions.\n\nExamples:\n\n```html\n@if (user; as u) { {{ u.name }} } // simple expression\n@if (user.profile; as profile /* : Profile */) { {{ profile.name }} } // complex expression\n```" + }, + "angular.inlayHints.variableTypes.letDeclarationTypes": { + "type": "boolean", + "default": false, + "markdownDescription": "Show type hints for `@let` declarations.\n\nExample:\n\n```html\n@let count /* : number */ = items.length\n```" + }, + "angular.inlayHints.variableTypes.referenceVariableTypes": { + "type": "boolean", + "default": false, + "markdownDescription": "Show type hints for template reference variables.\n\nExample:\n\n```html\n\n```" + }, + "angular.inlayHints.parameterHints.suppressWhenArgumentMatchesName": { + "type": "boolean", + "default": true, + "markdownDescription": "Suppress parameter name hints when argument name matches parameter name.\n\nExample:\n\n```html\n{{ onClick(user) }}\n```\n\nWhen enabled, suppresses hints where argument text already conveys the same name." + }, + "angular.inlayHints.variableTypes.suppressWhenTypeMatchesName": { + "type": "boolean", + "default": false, + "markdownDescription": "Suppress variable type hints when variable name matches type name (case-insensitive).\n\nExample:\n\n```html\n@let user = getUser()\n```\n\nWhen enabled, suppresses `/* : User */` for names like `user: User`." + } + } } ], "grammars": [ diff --git a/vscode-ng-language-service/server/src/handlers/initialization.ts b/vscode-ng-language-service/server/src/handlers/initialization.ts index 133944a53af..ae3b83f9411 100644 --- a/vscode-ng-language-service/server/src/handlers/initialization.ts +++ b/vscode-ng-language-service/server/src/handlers/initialization.ts @@ -52,6 +52,11 @@ export function onInitialize(session: Session, params: lsp.InitializeParams): ls // [here](https://github.com/angular/vscode-ng-language-service/issues/1828) codeActionKinds: [lsp.CodeActionKind.QuickFix], }, + // Inlay hints provider (LSP 3.17) + // Provides type annotations for template variables, $event types, etc. + inlayHintProvider: { + resolveProvider: true, + }, }, serverOptions, }; diff --git a/vscode-ng-language-service/server/src/handlers/inlay_hints.ts b/vscode-ng-language-service/server/src/handlers/inlay_hints.ts new file mode 100644 index 00000000000..ed897bccfd9 --- /dev/null +++ b/vscode-ng-language-service/server/src/handlers/inlay_hints.ts @@ -0,0 +1,343 @@ +/*! + * @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.dev/license + */ + +import * as lsp from 'vscode-languageserver/node'; +import * as ts from 'typescript/lib/tsserverlibrary'; +import {Session} from '../session'; +import {lspRangeToTsPositions} from '../utils'; +import {filePathToUri} from '../utils'; +import {getWorkspaceConfigurationCached, flattenConfiguration} from '../config'; +import { + isNgLanguageService, + AngularInlayHint, + InlayHintsConfig, +} from '@angular/language-service/api'; + +function isEditorInlayHintsEnabled(vsCodeConfig: Record): boolean { + const editorEnabled = vsCodeConfig['editor.inlayHints.enabled']; + return !(editorEnabled === 'off' || editorEnabled === false); +} + +/** + * Map VS Code's Angular inlay hints configuration to InlayHintsConfig. + */ +function mapAngularInlayHintsConfig(vsCodeConfig: Record): InlayHintsConfig { + const config: InlayHintsConfig = {}; + + const getConfigValue = (...keys: string[]): T | undefined => { + for (const key of keys) { + if (vsCodeConfig[key] !== undefined && vsCodeConfig[key] !== null) { + return vsCodeConfig[key] as T; + } + } + return undefined; + }; + + const forLoopVariableTypes = getConfigValue( + 'angular.inlayHints.variableTypes.forLoopVariableTypes', + ); + if (forLoopVariableTypes !== undefined) config.forLoopVariableTypes = forLoopVariableTypes; + + const ifAliasTypes = getConfigValue( + 'angular.inlayHints.variableTypes.ifAliasTypes', + ); + if (ifAliasTypes !== undefined) config.ifAliasTypes = ifAliasTypes; + + const letDeclarationTypes = getConfigValue( + 'angular.inlayHints.variableTypes.letDeclarationTypes', + ); + if (letDeclarationTypes !== undefined) config.letDeclarationTypes = letDeclarationTypes; + + const referenceVariableTypes = getConfigValue( + 'angular.inlayHints.variableTypes.referenceVariableTypes', + ); + if (referenceVariableTypes !== undefined) config.referenceVariableTypes = referenceVariableTypes; + + const suppressWhenTypeMatchesName = getConfigValue( + 'angular.inlayHints.variableTypes.suppressWhenTypeMatchesName', + ); + if (suppressWhenTypeMatchesName !== undefined) { + config.variableTypeHintsWhenTypeMatchesName = !suppressWhenTypeMatchesName; + } + + const arrowFunctionParameterTypes = getConfigValue( + 'angular.inlayHints.functionTypes.arrowFunctionParameterTypes', + ); + if (arrowFunctionParameterTypes !== undefined) { + config.arrowFunctionParameterTypes = arrowFunctionParameterTypes; + } + + const arrowFunctionReturnTypes = getConfigValue( + 'angular.inlayHints.functionTypes.arrowFunctionReturnTypes', + ); + if (arrowFunctionReturnTypes !== undefined) { + config.arrowFunctionReturnTypes = arrowFunctionReturnTypes; + } + + const parameterNameHints = getConfigValue<'none' | 'literals' | 'all'>( + 'angular.inlayHints.parameterHints.nameHints', + ); + if (parameterNameHints !== undefined) config.parameterNameHints = parameterNameHints; + + const suppressWhenArgumentMatchesName = getConfigValue( + 'angular.inlayHints.parameterHints.suppressWhenArgumentMatchesName', + ); + if (suppressWhenArgumentMatchesName !== undefined) { + config.parameterNameHintsWhenArgumentMatchesName = !suppressWhenArgumentMatchesName; + } + + const eventParameterTypes = getConfigValue( + 'angular.inlayHints.eventHints.parameterTypes', + ); + if (eventParameterTypes !== undefined) config.eventParameterTypes = eventParameterTypes; + + const propertyBindingTypes = getConfigValue( + 'angular.inlayHints.bindingHints.propertyBindingTypes', + ); + if (propertyBindingTypes !== undefined) config.propertyBindingTypes = propertyBindingTypes; + + const pipeOutputTypes = getConfigValue( + 'angular.inlayHints.bindingHints.pipeOutputTypes', + ); + if (pipeOutputTypes !== undefined) config.pipeOutputTypes = pipeOutputTypes; + + const twoWayBindingSignalTypes = getConfigValue( + 'angular.inlayHints.bindingHints.twoWayBindingSignalTypes', + ); + if (twoWayBindingSignalTypes !== undefined) { + config.twoWayBindingSignalTypes = twoWayBindingSignalTypes; + } + + const requiredInputIndicator = getConfigValue<'none' | 'asterisk' | 'exclamation'>( + 'angular.inlayHints.bindingHints.requiredInputIndicator', + ); + if (requiredInputIndicator !== undefined) { + config.requiredInputIndicator = requiredInputIndicator; + } + + const interactiveInlayHints = getConfigValue( + 'angular.inlayHints.interaction.interactiveInlayHints', + ); + if (interactiveInlayHints !== undefined) config.interactiveInlayHints = interactiveInlayHints; + + const hostListenerArgumentTypes = getConfigValue( + 'angular.inlayHints.eventHints.hostListenerArgumentTypes', + ); + if (hostListenerArgumentTypes !== undefined) { + config.hostListenerArgumentTypes = hostListenerArgumentTypes; + } + + const switchExpressionTypes = getConfigValue( + 'angular.inlayHints.controlFlowHints.switchExpressionTypes', + ); + if (switchExpressionTypes !== undefined) config.switchExpressionTypes = switchExpressionTypes; + + const deferTriggerTypes = getConfigValue( + 'angular.inlayHints.controlFlowHints.deferTriggerTypes', + ); + if (deferTriggerTypes !== undefined) config.deferTriggerTypes = deferTriggerTypes; + + return config; +} + +/** + * Handle the textDocument/inlayHint request (LSP 3.17). + * + * This handler provides inlay hints for Angular templates, showing: + * - Types of template variables (from *ngFor, @for, @if aliases) + * - Event parameter types ($event) + * - Pipe output types + * - Signal types + * + * This handler returns Angular-specific template inlay hints. + * TypeScript inlay hints are provided by TypeScript's own provider in VS Code. + */ +export async function onInlayHint( + session: Session, + params: lsp.InlayHintParams, +): Promise { + const lsInfo = session.getLSAndScriptInfo(params.textDocument); + if (!lsInfo) { + return null; + } + + const {languageService, scriptInfo} = lsInfo; + const hints: lsp.InlayHint[] = []; + + // Get the text span for the requested range + const [startOffset, endOffset] = lspRangeToTsPositions(scriptInfo, params.range); + const span = {start: startOffset, length: endOffset - startOffset}; + + // Request workspace configuration from the client for Angular inlay hints. + let angularConfig: InlayHintsConfig = {}; + + try { + // Request configuration from the client using shared config infrastructure + const configResult = await getWorkspaceConfigurationCached>( + session.connection, + [ + {section: 'angular.inlayHints', scopeUri: params.textDocument.uri}, + {section: 'editor.inlayHints', scopeUri: params.textDocument.uri}, + ], + ); + + if (configResult && configResult.length >= 2) { + // Flatten each config section using the shared utility + const flatConfig: Record = { + ...flattenConfiguration(configResult[0] ?? {}, 'angular.inlayHints'), + ...flattenConfiguration(configResult[1] ?? {}, 'editor.inlayHints'), + }; + + if (!isEditorInlayHintsEnabled(flatConfig)) { + return null; + } + angularConfig = mapAngularInlayHintsConfig(flatConfig); + } + } catch { + // If configuration request fails, use defaults + } + + // Get Angular-specific inlay hints for templates + // - @for item variable types: @for (user: User of users) + // - @if alias types: @if (data; as result: ApiResult) + // - $event parameter types: (click)="onClick($event: MouseEvent)" + // - Pipe output types: {{ value | async }} + // - @let declaration types + if (isNgLanguageService(languageService)) { + try { + const angularHints = languageService.getAngularInlayHints( + scriptInfo.fileName, + span, + angularConfig, + ); + for (const angularHint of angularHints) { + const position = scriptInfo.positionToLineOffset(angularHint.position); + hints.push(convertAngularInlayHint(session, angularHint, position)); + } + } catch (e) { + // Silently ignore - Angular hints are optional + } + } + + return hints.length > 0 ? hints : null; +} + +/** + * Handle the inlayHint/resolve request. + * + * This is called when the user requests lazy resolution for an inlay hint. + * For now, we just return the hint as-is since we don't have expensive data to defer. + */ +export function onInlayHintResolve(session: Session, hint: lsp.InlayHint): lsp.InlayHint { + // Currently, all our hints are fully computed upfront. + // In the future, we could defer tooltip computation here. + return hint; +} + +/** + * Convert an Angular InlayHint to an LSP InlayHint. + */ +function convertAngularInlayHint( + session: Session, + angularHint: AngularInlayHint, + position: {line: number; offset: number}, +): lsp.InlayHint { + // Convert to LSP's 0-based line/character + const lspPosition: lsp.Position = { + line: position.line - 1, + character: position.offset - 1, + }; + + // Map Angular hint kind to LSP InlayHintKind + const kind = angularHint.kind === 'Type' ? lsp.InlayHintKind.Type : lsp.InlayHintKind.Parameter; + + // Build the label - can be string or InlayHintLabelPart[] for interactive hints + let label: string | lsp.InlayHintLabelPart[]; + if (angularHint.displayParts && angularHint.displayParts.length > 0) { + // Interactive hints with navigation support + label = angularHint.displayParts.map((part) => { + const labelPart: lsp.InlayHintLabelPart = { + value: part.text, + }; + const location = getDisplayPartLocation(session, part); + if (location) { + labelPart.location = location; + } + return labelPart; + }); + } else { + // Non-interactive hints + label = angularHint.text; + } + + const hint: lsp.InlayHint = { + position: lspPosition, + label, + kind, + paddingLeft: angularHint.paddingLeft, + paddingRight: angularHint.paddingRight, + }; + + if (angularHint.tooltip) { + hint.tooltip = angularHint.tooltip; + } + + return hint; +} + +function getDisplayPartLocation( + session: Session, + part: { + span?: {start: number; length: number}; + file?: string; + }, +): lsp.Location | undefined { + if (!part.span || !part.file) { + return undefined; + } + + const scriptInfo = session.projectService.getScriptInfo(part.file); + if (scriptInfo) { + const start = scriptInfo.positionToLineOffset(part.span.start); + const end = scriptInfo.positionToLineOffset(part.span.start + part.span.length); + + return { + uri: filePathToUri(part.file), + range: { + start: {line: start.line - 1, character: start.offset - 1}, + end: {line: end.line - 1, character: end.offset - 1}, + }, + }; + } + + const text = readFileText(session, part.file); + if (text === undefined) { + return undefined; + } + + const sourceFile = ts.createSourceFile(part.file, text, ts.ScriptTarget.Latest, true); + const start = ts.getLineAndCharacterOfPosition(sourceFile, part.span.start); + const end = ts.getLineAndCharacterOfPosition(sourceFile, part.span.start + part.span.length); + + return { + uri: filePathToUri(part.file), + range: { + start: {line: start.line, character: start.character}, + end: {line: end.line, character: end.character}, + }, + }; +} + +function readFileText(session: Session, fileName: string): string | undefined { + const scriptInfo = session.projectService.getScriptInfo(fileName); + if (scriptInfo) { + const snapshot = scriptInfo.getSnapshot(); + return snapshot.getText(0, snapshot.getLength()); + } + return ts.sys.readFile(fileName); +} diff --git a/vscode-ng-language-service/server/src/session.ts b/vscode-ng-language-service/server/src/session.ts index 38673ad2461..891a28a85bb 100644 --- a/vscode-ng-language-service/server/src/session.ts +++ b/vscode-ng-language-service/server/src/session.ts @@ -6,7 +6,12 @@ * found in the LICENSE file at https://angular.dev/license */ -import {isNgLanguageService, NgLanguageService, PluginConfig} from '@angular/language-service/api'; +import { + isNgLanguageService, + NgLanguageService, + PluginConfig, + InlayHintsConfig, +} from '@angular/language-service/api'; import * as ts from 'typescript/lib/tsserverlibrary'; import {promisify} from 'util'; import * as lsp from 'vscode-languageserver/node'; @@ -47,6 +52,8 @@ import {onSignatureHelp} from './handlers/signature'; import {onGetTcb} from './handlers/tcb'; import {onGetTemplateLocationForComponent, isInAngularProject} from './handlers/template_info'; import {onDidChangeWatchedFiles} from './handlers/did_change_watched_files'; +import {onInlayHint, onInlayHintResolve} from './handlers/inlay_hints'; +import {clearWorkspaceConfigurationCache} from './config'; export interface SessionOptions { host: ServerHost; @@ -102,6 +109,11 @@ export class Session { readonly renameDisabledProjects: WeakSet = new WeakSet(); clientCapabilities: lsp.ClientCapabilities = {}; readonly defaultPreferences: ts.UserPreferences = {}; + /** + * Configuration for Angular inlay hints. + * Updated via workspace/didChangeConfiguration notifications. + */ + inlayHintsConfig: InlayHintsConfig = {}; constructor(options: SessionOptions) { this.includeAutomaticOptionalChainCompletions = @@ -230,6 +242,13 @@ export class Session { conn.onDidCloseTextDocument((p) => this.onDidCloseTextDocument(p)); conn.onDidChangeTextDocument((p) => this.onDidChangeTextDocument(p)); conn.onDidSaveTextDocument((p) => this.onDidSaveTextDocument(p)); + conn.onDidChangeConfiguration(() => { + clearWorkspaceConfigurationCache(this.connection); + // Apply updated inlay settings without requiring extension restart. + void this.connection.languages.inlayHint.refresh().catch(() => { + // Ignore if client does not support inlay hint refresh. + }); + }); conn.onDefinition((p) => onDefinition(this, p)); conn.onTypeDefinition((p) => onTypeDefinition(this, p)); conn.onReferences((p) => onReferences(this, p)); @@ -251,6 +270,10 @@ export class Session { conn.onSignatureHelp((p) => onSignatureHelp(this, p)); conn.onCodeAction((p) => onCodeAction(this, p)); conn.onCodeActionResolve(async (p) => await onCodeActionResolve(this, p)); + + // Inlay hints (LSP 3.17) + conn.onRequest(lsp.InlayHintRequest.type, (p) => onInlayHint(this, p)); + conn.onRequest(lsp.InlayHintResolveRequest.type, (p) => onInlayHintResolve(this, p)); } private enableLanguageServiceForProject(project: ts.server.Project): void {