diff --git a/.chronus/changes/witemple-msft-templateparameter-metaproperties-2026-2-2-13-17-55.md b/.chronus/changes/witemple-msft-templateparameter-metaproperties-2026-2-2-13-17-55.md new file mode 100644 index 00000000000..23c3b655aa1 --- /dev/null +++ b/.chronus/changes/witemple-msft-templateparameter-metaproperties-2026-2-2-13-17-55.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Enabled resolution of member properties and metaproperties through template parameters based on constraints. \ No newline at end of file diff --git a/.chronus/changes/witemple-msft-templateparameter-metaproperties-2026-2-2-15-5-30.md b/.chronus/changes/witemple-msft-templateparameter-metaproperties-2026-2-2-15-5-30.md new file mode 100644 index 00000000000..da7595d3f74 --- /dev/null +++ b/.chronus/changes/witemple-msft-templateparameter-metaproperties-2026-2-2-15-5-30.md @@ -0,0 +1,9 @@ +--- +changeKind: internal +packages: + - "@typespec/html-program-viewer" + - "@typespec/http-server-csharp" + - "@typespec/tspd" +--- + +Updated some packages to account for introduction of new TemplateParameterAccess virtual type. \ No newline at end of file diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 0d46a8aa6f9..4dc6a429451 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -149,6 +149,7 @@ import { SyntaxKind, TemplateArgumentNode, TemplateParameter, + TemplateParameterAccess, TemplateParameterDeclarationNode, TemplateableNode, TemplatedType, @@ -535,6 +536,9 @@ export function createChecker(program: Program, resolver: NameResolver): Checker * Tracking the template parameters used or not. */ const templateParameterUsageMap = new Map(); + const templateAccessSymbolCache = new Map(); + const symbolCacheIds = new WeakMap(); + let nextSymbolCacheId = 1; const checker: Checker = { getTypeForNode, @@ -747,7 +751,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker ); return errorType; } - if (entity.kind === "TemplateParameter") { + if (entity.kind === "TemplateParameter" || entity.kind === "TemplateParameterAccess") { if (entity.constraint?.valueType) { // means this template constraint will accept values reportCheckerDiagnostic( @@ -787,7 +791,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } // If a template parameter that can be a value is used in a template declaration then we allow it but we return null because we don't have an actual value. if ( - entity.kind === "TemplateParameter" && + (entity.kind === "TemplateParameter" || entity.kind === "TemplateParameterAccess") && entity.constraint?.valueType && entity.constraint.type === undefined && ctx.mapper === undefined @@ -1219,7 +1223,14 @@ export function createChecker(program: Program, resolver: NameResolver): Checker function visit(node: Node) { const entity = checkNode(ctx, node); let hasError = false; - if (entity !== null && "kind" in entity && entity.kind === "TemplateParameter") { + if ( + entity !== null && + "kind" in entity && + (entity.kind === "TemplateParameter" || entity.kind === "TemplateParameterAccess") + ) { + if (entity.kind === "TemplateParameterAccess") { + return entity; + } for (let i = index; i < templateParameters.length; i++) { if (entity.node?.symbol === templateParameters[i].symbol) { reportCheckerDiagnostic( @@ -2323,7 +2334,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker const indexers: ModelIndexer[] = []; const modelOptions: [Node, Model][] = options.filter((entry): entry is [Node, Model] => { const [optionNode, option] = entry; - if (option.kind === "TemplateParameter") { + if (option.kind === "TemplateParameter" || option.kind === "TemplateParameterAccess") { return false; } if (option.kind !== "Model") { @@ -3190,31 +3201,44 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } } } else if (identifier.parent && identifier.parent.kind === SyntaxKind.MemberExpression) { - let base = resolver.getNodeLinks(identifier.parent.base).resolvedSymbol; + const memberExpression = identifier.parent; + let baseType = getCompletionBaseType(memberExpression.base); - if (base) { - if (base.flags & SymbolFlags.Alias) { - base = getAliasedSymbol(CheckContext.DEFAULT, base); + let base = resolver.getNodeLinks(memberExpression.base).resolvedSymbol; + + if (base && base.flags & SymbolFlags.Alias) { + base = getAliasedSymbol(CheckContext.DEFAULT, base); + } + + if (!baseType && base) { + baseType = getCompletionBaseTypeFromSymbol(base); + } + + if (baseType && (!base || !!(base.flags & SymbolFlags.TemplateParameter))) { + if (memberExpression.selector === "::") { + addMetaCompletionsForType(baseType); + } else { + addMemberCompletionsForType(baseType); } + } - if (base) { - if (identifier.parent.selector === "::") { - if (base?.node === undefined && base?.declarations && base.declarations.length > 0) { - // Process meta properties separately, such as `::parameters`, `::returnType` - const nodeModels = base?.declarations[0]; - if (nodeModels.kind === SyntaxKind.OperationStatement) { - const operation = nodeModels as OperationStatementNode; - addCompletion("parameters", operation.symbol); - addCompletion("returnType", operation.symbol); - } - } else if (base?.node?.kind === SyntaxKind.ModelProperty) { - // Process meta properties separately, such as `::type` - const metaProperty = base.node as ModelPropertyNode; - addCompletion("type", metaProperty.symbol); + if (base) { + if (memberExpression.selector === "::") { + if (base?.node === undefined && base?.declarations && base.declarations.length > 0) { + // Process meta properties separately, such as `::parameters`, `::returnType` + const nodeModels = base?.declarations[0]; + if (nodeModels.kind === SyntaxKind.OperationStatement) { + const operation = nodeModels as OperationStatementNode; + addCompletion("parameters", operation.symbol); + addCompletion("returnType", operation.symbol); } - } else { - addCompletions(base.exports ?? base.members); + } else if (base?.node?.kind === SyntaxKind.ModelProperty) { + // Process meta properties separately, such as `::type` + const metaProperty = base.node as ModelPropertyNode; + addCompletion("type", metaProperty.symbol); } + } else { + addCompletions(base.exports ?? base.members); } } } else { @@ -3267,6 +3291,115 @@ export function createChecker(program: Program, resolver: NameResolver): Checker return completions; + /** Resolve a usable base type for member/meta-member completions. */ + function getCompletionBaseType(base: IdentifierNode | MemberExpressionNode): Type | undefined { + const entity = getTypeOrValueForNode(base, CheckContext.DEFAULT); + if (!entity || !isType(entity) || isErrorType(entity)) { + if (base.kind === SyntaxKind.Identifier) { + const scopedTemplateParameter = getTemplateParameterTypeFromScope(base); + if (scopedTemplateParameter) { + return resolveTemplateConstraintType(scopedTemplateParameter); + } + } + const templateBase = probeTemplateAccessBaseEntity(base); + return templateBase ? resolveTemplateConstraintType(templateBase) : undefined; + } + + if (isTemplateAccessType(entity)) { + return resolveTemplateConstraintType(entity); + } + + return entity; + } + + /** Resolve a completion base type from a symbol when node-based typing is unavailable. */ + function getCompletionBaseTypeFromSymbol(base: Sym): Type | undefined { + if (base.flags & SymbolFlags.LateBound) { + const lateBoundType = base.type; + return lateBoundType && isType(lateBoundType) + ? resolveCompletionType(lateBoundType) + : undefined; + } + + if (base.flags & SymbolFlags.TemplateParameter) { + const mapped = checkTemplateParameterDeclaration( + CheckContext.DEFAULT, + getSymNode(base) as TemplateParameterDeclarationNode, + ); + return isType(mapped) ? resolveCompletionType(mapped) : undefined; + } + + return undefined; + } + + /** Normalize template access types to their effective constraint for completion. */ + function resolveCompletionType(type: Type): Type | undefined { + return isTemplateAccessType(type) ? resolveTemplateConstraintType(type) : type; + } + + /** Add member completions based on the resolved base type kind. */ + function addMemberCompletionsForType(baseType: Type) { + switch (baseType.kind) { + case "Model": + for (const property of walkPropertiesInherited(baseType)) { + const ownerSymbol = baseType.node?.symbol; + const propertySymbol = + (ownerSymbol ? getMemberSymbol(ownerSymbol, property.name) : undefined) ?? + property.node?.symbol; + if (propertySymbol) { + addCompletion(property.name, propertySymbol); + } + } + return; + case "Interface": + for (const [name, operation] of baseType.operations) { + const operationSymbol = operation.node?.symbol; + if (operationSymbol) { + addCompletion(name, operationSymbol); + } + } + return; + case "Enum": + for (const [name, member] of baseType.members) { + const enumMemberSymbol = member.node?.symbol; + if (enumMemberSymbol) { + addCompletion(name, enumMemberSymbol); + } + } + return; + case "Union": + for (const [name, variant] of baseType.variants) { + if (typeof name === "string" && variant.node?.symbol) { + addCompletion(name, variant.node.symbol); + } + } + return; + case "Scalar": + for (const [name, constructor] of baseType.constructors) { + const constructorSymbol = constructor.node?.symbol; + if (constructorSymbol) { + addCompletion(name, constructorSymbol); + } + } + return; + case "Namespace": + addCompletions(baseType.node?.symbol?.exports); + return; + } + } + + /** Add `::` meta-member completions for the resolved base type. */ + function addMetaCompletionsForType(baseType: Type) { + const baseSymbol = getTypeSymbol(baseType); + if (!baseSymbol) { + return; + } + + for (const metaMemberName of resolver.getMetaMemberNames(baseSymbol)) { + addCompletion(metaMemberName, baseSymbol); + } + } + function addCompletions(table: SymbolTable | undefined) { if (!table) { return; @@ -3361,7 +3494,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker node, resolvedOptions as SymbolResolutionOptions & { locationContext: LocationContext }, ); - if (!resolvedOptions.resolveDeclarationOfTemplate) { + if (ctx.mapper === undefined && !resolvedOptions.resolveDeclarationOfTemplate) { referenceSymCache.set(node, sym); } return sym; @@ -3415,6 +3548,11 @@ export function createChecker(program: Program, resolver: NameResolver): Checker return undefined; } + const directTemplateAccessSym = tryResolveTemplateAccessSymbol(ctx, node, base); + if (directTemplateAccessSym) { + return directTemplateAccessSym; + } + // when resolving a type reference based on an alias, unwrap the alias. if (base.flags & SymbolFlags.Alias) { if (!options.resolveDeclarationOfTemplate && isTemplatedNode(getSymNode(base))) { @@ -3466,7 +3604,12 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } base = baseSym; } - const sym = resolveMemberInContainer(base, node, options); + const templateAccessSym = tryResolveTemplateAccessSymbol(ctx, node, base); + if (templateAccessSym) { + return templateAccessSym; + } + + const sym = resolveMemberInContainer(ctx, base, node, options); checkSymbolAccess(options.locationContext, node, sym); @@ -3476,6 +3619,545 @@ export function createChecker(program: Program, resolver: NameResolver): Checker compilerAssert(false, `Unknown type reference kind "${SyntaxKind[(node as any).kind]}"`, node); } + /** + * Resolve member/meta-member access rooted in a template parameter or template access chain. + * Falls back to late-bound symbols when the concrete symbol cannot be safely determined. + * + * @param ctx Check context for mapper and usage observation. + * @param node Member expression being resolved. + * @param baseSym Resolved symbol for the member expression base. + * @returns The resolved symbol for the template access, or `undefined` when not applicable. + */ + function tryResolveTemplateAccessSymbol( + ctx: CheckContext, + node: MemberExpressionNode, + baseSym: Sym, + ): Sym | undefined { + const mappedSymbol = tryResolveMappedTemplateAccessSymbol(ctx, node, baseSym); + if (mappedSymbol) { + return mappedSymbol; + } + + const baseEntity = getTemplateAccessBaseEntity(ctx, node.base, baseSym); + if (!baseEntity) { + return undefined; + } + + observeTemplateAccessBase(ctx, baseEntity); + const accessedType = resolveTemplateAccessType(ctx, node, baseEntity); + const useCache = ctx.mapper === undefined; + if (isErrorType(accessedType)) { + return createTemplateAccessSymbol(baseEntity, node, errorType, useCache); + } + + if ( + node.selector === "." && + baseEntity.kind === "TemplateParameterAccess" && + baseEntity.node.selector === "::" + ) { + if (shouldUseLateBoundTemplateAccessType(accessedType)) { + return createLateBoundTypeSymbol(node, accessedType); + } + return getTypeSymbol(accessedType) ?? createLateBoundTypeSymbol(node, accessedType); + } + + if (ctx.mapper !== undefined) { + if (shouldUseLateBoundTemplateAccessType(accessedType)) { + return createLateBoundTypeSymbol(node, accessedType); + } + return getTypeSymbol(accessedType) ?? createLateBoundTypeSymbol(node, accessedType); + } + + return createTemplateAccessSymbol(baseEntity, node, accessedType, useCache); + } + + /** + * Resolve template access directly from a mapped template argument when available. + * + * @param ctx Check context containing the active template mapper. + * @param node Member expression being resolved. + * @param baseSym Base symbol for the access expression. + * @returns A concrete or late-bound symbol for the mapped access, or `undefined`. + */ + function tryResolveMappedTemplateAccessSymbol( + ctx: CheckContext, + node: MemberExpressionNode, + baseSym: Sym, + ): Sym | undefined { + if (!ctx.mapper || !(baseSym.flags & SymbolFlags.TemplateParameter)) { + return undefined; + } + + const declared = checkTemplateParameterDeclaration( + CheckContext.DEFAULT, + getSymNode(baseSym) as TemplateParameterDeclarationNode, + ); + if (!isType(declared) || declared.kind !== "TemplateParameter") { + return undefined; + } + + const mapped = ctx.mapper.getMappedType(declared); + if (!isType(mapped) || isTemplateAccessType(mapped)) { + return undefined; + } + if (isUninstantiatedTemplateType(mapped)) { + return undefined; + } + + const resolvedType = + node.selector === "." + ? resolveMemberTypeFromConstraint(mapped, node.id.sv) + : resolveMetaTypeFromConstraint(ctx, mapped, node); + if (!resolvedType) { + return undefined; + } + + if (shouldUseLateBoundTemplateAccessType(resolvedType)) { + return createLateBoundTypeSymbol(node, resolvedType); + } + return getTypeSymbol(resolvedType) ?? createLateBoundTypeSymbol(node, resolvedType); + } + + /** Return true when a type declaration is templated but has not been instantiated. */ + function isUninstantiatedTemplateType(type: Type): boolean { + if ("templateMapper" in type && type.templateMapper !== undefined) { + return false; + } + const node = type.node; + return Boolean(node && "templateParameters" in node && node.templateParameters.length > 0); + } + + /** Return true when the resolved type should remain late-bound due to templating. */ + function shouldUseLateBoundTemplateAccessType(type: Type): boolean { + return "templateMapper" in type && type.templateMapper !== undefined; + } + + /** + * Resolve the template entity (parameter or access) that acts as the base for a member expression. + */ + function getTemplateAccessBaseEntity( + _ctx: CheckContext, + baseNode: IdentifierNode | MemberExpressionNode, + baseSym: Sym, + ): TemplateParameter | TemplateParameterAccess | undefined { + if (baseSym.flags & SymbolFlags.LateBound) { + const lateBoundType = baseSym.type; + if (lateBoundType && lateBoundType.kind === "TemplateParameterAccess") { + return lateBoundType; + } + return undefined; + } + + if (baseSym.flags & SymbolFlags.TemplateParameter) { + const baseSymbolNode = getSymNode(baseSym); + const mapped = checkTemplateParameterDeclaration( + CheckContext.DEFAULT, + baseSymbolNode as TemplateParameterDeclarationNode, + ); + return isType(mapped) && isTemplateAccessType(mapped) ? mapped : undefined; + } + + if (baseNode.kind === SyntaxKind.Identifier) { + const templateParameterType = getTemplateParameterTypeFromScope(baseNode); + if (templateParameterType) { + return templateParameterType; + } + return undefined; + } + + return probeTemplateAccessBaseEntity(baseNode); + } + + /** Probe a node for a template access base without surfacing diagnostics. */ + function probeTemplateAccessBaseEntity( + node: IdentifierNode | MemberExpressionNode, + ): TemplateParameter | TemplateParameterAccess | undefined { + const oldDiagnosticHook = onCheckerDiagnostic; + onCheckerDiagnostic = () => {}; + const entity = checkTypeOrValueReference(CheckContext.DEFAULT, node, false); + onCheckerDiagnostic = oldDiagnosticHook; + return isType(entity) && isTemplateAccessType(entity) ? entity : undefined; + } + + /** Resolve a template parameter type from lexical scope by identifier name. */ + function getTemplateParameterTypeFromScope( + identifier: IdentifierNode, + ): TemplateParameter | TemplateParameterAccess | undefined { + const declaration = findTemplateParameterDeclarationInScope(identifier, identifier.sv); + if (!declaration) { + return undefined; + } + + const mapped = checkTemplateParameterDeclaration(CheckContext.DEFAULT, declaration); + return isType(mapped) && isTemplateAccessType(mapped) ? mapped : undefined; + } + + /** Find the closest template parameter declaration matching the given name. */ + function findTemplateParameterDeclarationInScope( + node: Node, + name: string, + ): TemplateParameterDeclarationNode | undefined { + let current: Node | undefined = node.parent; + while (current) { + if ("templateParameters" in current && current.templateParameters) { + const declaration = current.templateParameters.find((x) => x.id.sv === name); + if (declaration) { + return declaration; + } + } + current = current.parent; + } + return undefined; + } + + /** + * Resolve the resulting type for a template parameter access expression. + * + * @param ctx Check context for mapper-aware resolution. + * @param node Access expression (`.` or `::`) being resolved. + * @param baseEntity Template parameter or prior template access chain. + * @returns The resolved member/meta-member type, or `errorType` when not guaranteed. + */ + function resolveTemplateAccessType( + ctx: CheckContext, + node: MemberExpressionNode, + baseEntity: TemplateParameter | TemplateParameterAccess, + ): Type { + const baseType = resolveTemplateAccessBaseType(ctx, baseEntity); + if (!baseType) { + if (hasErrorTemplateConstraint(baseEntity)) { + return errorType; + } + reportTemplateAccessNotGuaranteed(node, baseEntity); + return errorType; + } + + const resolvedType = + node.selector === "." + ? resolveMemberTypeFromConstraint(baseType, node.id.sv) + : resolveMetaTypeFromConstraint(ctx, baseType, node); + + if (!resolvedType) { + reportTemplateAccessNotGuaranteed(node, baseType); + return errorType; + } + + return resolvedType; + } + + /** + * Resolve the concrete base type that a template access should evaluate against. + * + * @param ctx Check context containing optional template mapper. + * @param baseEntity Template parameter/access entity. + * @returns The mapped or constrained base type, if determinable. + */ + function resolveTemplateAccessBaseType( + ctx: CheckContext, + baseEntity: TemplateParameter | TemplateParameterAccess, + ): Type | undefined { + if (ctx.mapper && baseEntity.kind === "TemplateParameterAccess") { + const mappedBaseType = resolveTemplateAccessBaseType(ctx, baseEntity.base); + if (!mappedBaseType) { + return undefined; + } + + return baseEntity.node.selector === "." + ? resolveMemberTypeFromConstraint(mappedBaseType, baseEntity.node.id.sv) + : resolveMetaTypeFromConstraint(ctx, mappedBaseType, baseEntity.node); + } + + if (ctx.mapper && baseEntity.kind === "TemplateParameter") { + const mapped = ctx.mapper.getMappedType(baseEntity); + if (isType(mapped)) { + if (isTemplateAccessType(mapped)) { + return resolveTemplateConstraintType(mapped); + } + if (isUninstantiatedTemplateType(mapped)) { + return resolveTemplateConstraintType(baseEntity); + } + return mapped; + } + } + return resolveTemplateConstraintType(baseEntity); + } + + /** + * Resolve the terminal non-template constraint type for a template access chain. + * + * @param templateType Template parameter or access node. + * @returns The terminal constrained type, or `undefined` when missing/invalid/cyclic. + */ + function resolveTemplateConstraintType( + templateType: TemplateParameter | TemplateParameterAccess, + ): Type | undefined { + const visited = new Set(); + let current: TemplateParameter | TemplateParameterAccess = templateType; + while (true) { + if (visited.has(current)) { + return undefined; + } + visited.add(current); + + const constraintType = current.constraint?.type; + if (!constraintType || isErrorType(constraintType)) { + return undefined; + } + if (!isTemplateAccessType(constraintType)) { + return constraintType; + } + current = constraintType; + } + } + + /** Return true when a template access chain includes an error constraint. */ + function hasErrorTemplateConstraint( + templateType: TemplateParameter | TemplateParameterAccess, + ): boolean { + let current: TemplateParameter | TemplateParameterAccess = templateType; + while (true) { + const constraintType = current.constraint?.type; + if (constraintType && isErrorType(constraintType)) { + return true; + } + if (current.kind !== "TemplateParameterAccess") { + return false; + } + current = current.base; + } + } + + /** Track template parameter usage for a template access base chain. */ + function observeTemplateAccessBase( + ctx: CheckContext, + base: TemplateParameter | TemplateParameterAccess, + ) { + const root = getTemplateAccessRoot(base); + ctx.observeTemplateParameter(root); + templateParameterUsageMap.set(root.node, true); + } + + /** Return the root template parameter for a template access chain. */ + function getTemplateAccessRoot( + base: TemplateParameter | TemplateParameterAccess, + ): TemplateParameter { + let current: TemplateParameter | TemplateParameterAccess = base; + while (current.kind === "TemplateParameterAccess") { + current = current.base; + } + return current; + } + + /** Resolve `.` access from a constrained type by kind-specific member lookup. */ + function resolveMemberTypeFromConstraint( + constraintType: Type, + memberName: string, + ): Type | undefined { + switch (constraintType.kind) { + case "Model": + for (const property of walkPropertiesInherited(constraintType)) { + if (property.name === memberName) { + return property; + } + } + return undefined; + case "Interface": + return constraintType.operations.get(memberName); + case "Enum": + return constraintType.members.get(memberName); + case "Union": + return constraintType.variants.get(memberName); + case "Scalar": + return constraintType.constructors.get(memberName); + default: + return undefined; + } + } + + /** + * Resolve `::` meta-member access from a constrained type. + * + * @param ctx Check context used for symbol-to-entity evaluation. + * @param constraintType Base constrained type. + * @param node Meta-member expression node. + * @returns The resolved meta-member type, `unknownType` for projection-only cases, or `undefined`. + */ + function resolveMetaTypeFromConstraint( + ctx: CheckContext, + constraintType: Type, + node: MemberExpressionNode, + ): Type | undefined { + if (constraintType.kind === "ModelProperty" && node.id.sv === "type") { + return constraintType.type; + } + if (constraintType.kind === "Operation") { + switch (node.id.sv) { + case "parameters": + return constraintType.parameters; + case "returnType": + return constraintType.returnType; + } + } + + const constraintSymbol = getTypeSymbol(constraintType); + if (!constraintSymbol) { + return undefined; + } + + const metaMemberNames = resolver.getMetaMemberNames(constraintSymbol); + if (!metaMemberNames.includes(node.id.sv)) { + return undefined; + } + + if (isReflectionMetaProjectionSymbol(constraintSymbol)) { + // Reflection model symbols expose meta-member names by projection, but their + // underlying nodes are not concrete ModelProperty/Operation nodes. + return unknownType; + } + + const resolved = resolver.resolveMetaMemberByName(constraintSymbol, node.id.sv); + if (resolved.resolutionResult & ResolutionResultFlags.Resolved && resolved.resolvedSymbol) { + const entity = checkTypeOrValueReferenceSymbol(ctx, resolved.resolvedSymbol, node, false); + if (entity === null) { + return undefined; + } + if (entity.entityKind === "Indeterminate") { + return entity.type; + } + return isType(entity) ? entity : undefined; + } + + return unknownType; + } + + /** Return true for TypeSpec.Reflection model symbols backed by projection metadata. */ + function isReflectionMetaProjectionSymbol(sym: Sym): boolean { + return ( + sym.node?.kind === SyntaxKind.ModelStatement && + sym.parent?.name === "Reflection" && + sym.parent?.parent?.name === "TypeSpec" + ); + } + + /** Report an invalid-ref diagnostic for unsupported template member/meta-member access. */ + function reportTemplateAccessNotGuaranteed( + node: MemberExpressionNode, + baseType: Type | TemplateParameter | TemplateParameterAccess, + ) { + reportCheckerDiagnostic( + createDiagnostic({ + code: "invalid-ref", + messageId: node.selector === "." ? "member" : "metaProperty", + format: { kind: getTemplateAccessKindName(baseType), id: node.id.sv }, + target: node, + }), + ); + } + + /** Get the diagnostic kind label used when template access resolution fails. */ + function getTemplateAccessKindName( + type: Type | TemplateParameter | TemplateParameterAccess, + ): string { + switch (type.kind) { + case "Model": + case "ModelProperty": + case "Enum": + case "Interface": + case "Union": + case "Operation": + case "Scalar": + case "TemplateParameter": + case "TemplateParameterAccess": + return type.kind; + default: + return "Type"; + } + } + + /** Type guard for template parameters and template parameter access types. */ + function isTemplateAccessType(type: Type): type is TemplateParameter | TemplateParameterAccess { + return type.kind === "TemplateParameter" || type.kind === "TemplateParameterAccess"; + } + + /** + * Create (or retrieve from cache) a late-bound symbol representing template access. + * + * @param base Template parameter/access base. + * @param node Member expression node. + * @param constraintType Resolved constraint for the access result. + * @param useCache Whether to reuse/access symbol cache. + * @returns A symbol whose type is `TemplateParameterAccess`. + */ + function createTemplateAccessSymbol( + base: TemplateParameter | TemplateParameterAccess, + node: MemberExpressionNode, + constraintType: Type, + useCache = true, + ): Sym { + const cacheKey = getTemplateAccessCacheKey(base, node); + if (useCache) { + const existing = templateAccessSymbolCache.get(cacheKey); + if (existing) { + return existing; + } + } + + const constraint = { + entityKind: "MixedParameterConstraint", + node, + type: constraintType, + } satisfies MixedParameterConstraint; + + const type = createAndFinishType({ + kind: "TemplateParameterAccess", + node, + base, + path: getTemplateAccessPath(base) + node.selector + node.id.sv, + cacheKey, + constraint, + }); + + const symbol = createSymbol(node, node.id.sv, SymbolFlags.LateBound); + mutate(symbol).type = type; + if (useCache) { + templateAccessSymbolCache.set(cacheKey, symbol); + } + return symbol; + } + + /** Compute the user-facing access path for a template access chain. */ + function getTemplateAccessPath(base: TemplateParameter | TemplateParameterAccess): string { + return base.kind === "TemplateParameterAccess" ? base.path : base.node.id.sv; + } + + /** Build a stable cache key for a template access symbol/type chain. */ + function getTemplateAccessCacheKey( + base: TemplateParameter | TemplateParameterAccess, + node: MemberExpressionNode, + ): string { + const baseKey = + base.kind === "TemplateParameterAccess" + ? base.cacheKey + : `tp:${getSymbolCacheId(base.node.symbol)}`; + return `${baseKey}${node.selector}${node.id.sv}`; + } + + /** Resolve the merged symbol associated with a type, when one exists. */ + function getTypeSymbol(type: Type): Sym | undefined { + return type.node?.symbol ? getMergedSymbol(type.node.symbol) : undefined; + } + + /** Get a stable numeric id for a symbol used in template access cache keys. */ + function getSymbolCacheId(sym: Sym): number { + const existing = symbolCacheIds.get(sym); + if (existing !== undefined) { + return existing; + } + + const id = nextSymbolCacheId++; + symbolCacheIds.set(sym, id); + return id; + } function checkSymbolAccess(sourceLocation: LocationContext, node: Node, symbol: Sym | undefined) { if (!symbol) return; @@ -3519,7 +4201,6 @@ export function createChecker(program: Program, resolver: NameResolver): Checker ); } } - function reportAmbiguousIdentifier(node: IdentifierNode, symbols: Sym[]) { const duplicateNames = symbols.map((s) => getFullyQualifiedSymbolName(s, { useGlobalPrefixAtTopLevel: true }), @@ -3534,10 +4215,16 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } function resolveMemberInContainer( + ctx: CheckContext, base: Sym, node: MemberExpressionNode, options: SymbolResolutionOptions, ) { + const symbolFromType = resolveMemberOnSymbolType(ctx, base, node); + if (symbolFromType) { + return symbolFromType; + } + const { finalSymbol: sym, resolvedSymbol: nextSym } = resolver.resolveMemberExpressionForSym( base, node, @@ -3622,6 +4309,92 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } } + /** + * Resolve a member/meta-member access from the base symbol's resolved type. + * + * @param ctx Check context. + * @param base Base symbol. + * @param node Member expression to resolve. + * @returns Concrete symbol, late-bound symbol, or `undefined` when unresolved. + */ + function resolveMemberOnSymbolType( + ctx: CheckContext, + base: Sym, + node: MemberExpressionNode, + ): Sym | undefined { + const baseType = getMemberResolutionType(ctx, base); + if (!baseType) { + return undefined; + } + + const resolvedBaseType = isTemplateAccessType(baseType) + ? resolveTemplateAccessBaseType(ctx, baseType) + : baseType; + if (!resolvedBaseType) { + return undefined; + } + + const resolvedType = + node.selector === "." + ? resolveMemberTypeFromConstraint(resolvedBaseType, node.id.sv) + : resolveMetaTypeFromConstraint(ctx, resolvedBaseType, node); + if (!resolvedType) { + return undefined; + } + + if (node.selector === ".") { + const table = base.exports ?? base.members; + if (table) { + const directMember = resolver.getAugmentedSymbolTable(table).get(node.id.sv); + if (directMember) { + return directMember; + } + } + } + + if ( + node.selector === "::" && + node.id.sv === "type" && + resolvedType.kind === "TemplateParameterAccess" && + resolvedType.base.kind === "TemplateParameterAccess" && + resolvedType.base.node.selector === "." + ) { + const sourceProperty = resolveTemplateConstraintType(resolvedType.base); + if (sourceProperty) { + return getTypeSymbol(sourceProperty) ?? createLateBoundTypeSymbol(node, sourceProperty); + } + } + + const resolvedSymbol = getTypeSymbol(resolvedType); + if ( + resolvedSymbol && + !("templateMapper" in resolvedType && resolvedType.templateMapper !== undefined) + ) { + return resolvedSymbol; + } + + return createLateBoundTypeSymbol(node, resolvedType); + } + + /** Resolve the effective type used for member lookup on a base symbol. */ + function getMemberResolutionType(ctx: CheckContext, base: Sym): Type | undefined { + if (base.flags & SymbolFlags.LateBound) { + return base.type && isType(base.type) ? base.type : undefined; + } + if (base.flags & SymbolFlags.Member) { + const type = checkMemberSym(ctx, base); + return isErrorType(type) ? undefined : type; + } + return undefined; + } + + /** Create a late-bound symbol carrying a precomputed type for member resolution. */ + function createLateBoundTypeSymbol(node: MemberExpressionNode, type: Type): Sym { + const symbol = createSymbol(node, node.id.sv, SymbolFlags.LateBound); + mutate(symbol).type = type; + return symbol; + } + /** * Return the symbol that is aliased by this alias declaration. If no such symbol is aliased, * return the symbol for the alias instead. For member containers which need to be late bound @@ -3632,7 +4405,12 @@ export function createChecker(program: Program, resolver: NameResolver): Checker const node = getSymNode(aliasSymbol); const links = resolver.getSymbolLinks(aliasSymbol); if (!links.aliasResolutionIsTemplate) { - return links.aliasedSymbol ?? resolver.getNodeLinks(node).resolvedSymbol; + const aliased = links.aliasedSymbol ?? resolver.getNodeLinks(node).resolvedSymbol; + if (aliased && isTemplatedNode(getSymNode(aliased))) { + const aliasType = getTypeForNode(node as AliasStatementNode, ctx); + return lateBindContainer(aliasType, aliasSymbol); + } + return aliased; } // Otherwise for templates we need to get the type and retrieve the late bound symbol. @@ -3705,7 +4483,11 @@ export function createChecker(program: Program, resolver: NameResolver): Checker if (typeOrValue !== null) { if (isValue(typeOrValue)) { hasValue = true; - } else if ("kind" in typeOrValue && typeOrValue.kind === "TemplateParameter") { + } else if ( + "kind" in typeOrValue && + (typeOrValue.kind === "TemplateParameter" || + typeOrValue.kind === "TemplateParameterAccess") + ) { if (typeOrValue.constraint) { if (typeOrValue.constraint.valueType) { hasValue = true; @@ -4542,7 +5324,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker if (target.entityKind === "Type") { if (target.kind === "Scalar" || target.kind === "ScalarConstructor") { return target; - } else if (target.kind === "TemplateParameter") { + } else if (target.kind === "TemplateParameter" || target.kind === "TemplateParameterAccess") { const callable = target.constraint && constraintIsCallable(target.constraint); if (!callable) { reportCheckerDiagnostic( @@ -5169,7 +5951,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } if (isType(entity)) { - if (entity.kind === "TemplateParameter") { + if (entity.kind === "TemplateParameter" || entity.kind === "TemplateParameterAccess") { if (entity.constraint === undefined || entity.constraint.type !== undefined) { // means this template constraint will accept values reportCheckerDiagnostic( @@ -5526,7 +6308,11 @@ export function createChecker(program: Program, resolver: NameResolver): Checker ): [ModelProperty[], ModelIndexer | undefined] { const targetType = getTypeForNode(targetNode, ctx); - if (targetType.kind === "TemplateParameter" || isErrorType(targetType)) { + if ( + targetType.kind === "TemplateParameter" || + targetType.kind === "TemplateParameterAccess" || + isErrorType(targetType) + ) { return [[], undefined]; } if (targetType.kind !== "Model") { diff --git a/packages/compiler/src/core/helpers/string-template-utils.ts b/packages/compiler/src/core/helpers/string-template-utils.ts index 349f74ad746..e0d9e8216b3 100644 --- a/packages/compiler/src/core/helpers/string-template-utils.ts +++ b/packages/compiler/src/core/helpers/string-template-utils.ts @@ -31,6 +31,7 @@ export function explainStringTemplateNotSerializable( diagnostics.pipe(isStringTemplateSerializable(span.type)); break; case "TemplateParameter": + case "TemplateParameterAccess": if (span.type.constraint && span.type.constraint.valueType !== undefined) { break; // Value types will be serializable in the template instance. } diff --git a/packages/compiler/src/core/helpers/type-name-utils.ts b/packages/compiler/src/core/helpers/type-name-utils.ts index 6b71b1e7bf0..2c4dd6b888d 100644 --- a/packages/compiler/src/core/helpers/type-name-utils.ts +++ b/packages/compiler/src/core/helpers/type-name-utils.ts @@ -31,6 +31,8 @@ export function getTypeName(type: Type, options?: TypeNameOptions): string { return getNamespaceFullName(type, options); case "TemplateParameter": return getIdentifierName(type.node.id.sv, options); + case "TemplateParameterAccess": + return getIdentifierName(type.path, options); case "Scalar": return getScalarName(type, options); case "Model": diff --git a/packages/compiler/src/core/name-resolver.ts b/packages/compiler/src/core/name-resolver.ts index 186de98e8c1..0712e85a9a0 100644 --- a/packages/compiler/src/core/name-resolver.ts +++ b/packages/compiler/src/core/name-resolver.ts @@ -136,6 +136,8 @@ export interface NameResolver { /** Get the meta member by name */ resolveMetaMemberByName(sym: Sym, name: string): ResolutionResult; + /** Get the list of available meta member names */ + getMetaMemberNames(sym: Sym): readonly string[]; /** Resolve the given type reference. This should only need to be called on dynamically created nodes that want to resolve which symbol they reference */ resolveTypeReference( @@ -228,6 +230,7 @@ export function createResolver(program: Program): NameResolver { resolveMemberExpressionForSym, resolveMetaMemberByName, + getMetaMemberNames, resolveTypeReference, getAugmentDecoratorsForSym, @@ -730,6 +733,38 @@ export function createResolver(program: Program): NameResolver { return getter(baseSym); } + /** Get the available meta-member names for a symbol's meta-type prototype. */ + function getMetaMemberNames(baseSym: Sym): readonly string[] { + const baseNode = getSymNode(baseSym); + const prototype = getMetaTypePrototypeForSymbol(baseSym, baseNode); + return prototype ? [...prototype.keys()] : []; + } + + /** + * Resolve the meta-type prototype for a symbol, including Reflection model aliases. + */ + function getMetaTypePrototypeForSymbol(baseSym: Sym, baseNode: Node): TypePrototype | undefined { + const prototype = metaTypePrototypes.get(baseNode.kind); + if (prototype) { + return prototype; + } + + if ( + baseNode.kind === SyntaxKind.ModelStatement && + baseSym.parent?.name === "Reflection" && + baseSym.parent?.parent?.name === "TypeSpec" + ) { + switch (baseSym.name) { + case "ModelProperty": + return metaTypePrototypes.get(SyntaxKind.ModelProperty); + case "Operation": + return metaTypePrototypes.get(SyntaxKind.OperationStatement); + } + } + + return undefined; + } + function tableLookup(table: SymbolTable, node: IdentifierNode, resolveDecorator = false) { table = augmentedSymbolTables.get(table) ?? table; let sym; diff --git a/packages/compiler/src/core/semantic-walker.ts b/packages/compiler/src/core/semantic-walker.ts index 7f08ea16fb3..051ac9581cf 100644 --- a/packages/compiler/src/core/semantic-walker.ts +++ b/packages/compiler/src/core/semantic-walker.ts @@ -16,6 +16,7 @@ import { StringTemplate, StringTemplateSpan, TemplateParameter, + TemplateParameterAccess, Tuple, Type, TypeListeners, @@ -386,6 +387,17 @@ function navigateTemplateParameter(type: TemplateParameter, context: NavigationC if (context.emit("templateParameter", type) === ListenerFlow.NoRecursion) return; } +/** Emit semantic walker events for template parameter access nodes. */ +function navigateTemplateParameterAccess( + type: TemplateParameterAccess, + context: NavigationContext, +) { + if (checkVisited(context.visited, type)) { + return; + } + if (context.emit("templateParameterAccess", type) === ListenerFlow.NoRecursion) return; +} + function navigateDecoratorDeclaration(type: Decorator, context: NavigationContext) { if (checkVisited(context.visited, type)) { return; @@ -436,6 +448,8 @@ function navigateTypeInternal(entity: Type | Value, context: NavigationContext) return navigateStringTemplateSpan(entity, context); case "TemplateParameter": return navigateTemplateParameter(entity, context); + case "TemplateParameterAccess": + return navigateTemplateParameterAccess(entity, context); case "Decorator": return navigateDecoratorDeclaration(entity, context); case "ScalarConstructor": @@ -499,7 +513,9 @@ export class EventEmitter any }> { const eventNames: Array = [ "root", "templateParameter", + "templateParameterAccess", "exitTemplateParameter", + "exitTemplateParameterAccess", "scalar", "exitScalar", "model", diff --git a/packages/compiler/src/core/type-relation-checker.ts b/packages/compiler/src/core/type-relation-checker.ts index fbec81007d1..4b0e595a8d3 100644 --- a/packages/compiler/src/core/type-relation-checker.ts +++ b/packages/compiler/src/core/type-relation-checker.ts @@ -248,7 +248,10 @@ export function createTypeRelationChecker(program: Program, checker: Checker): T diagnosticTarget: Entity | Node, relationCache: MultiKeyMap<[Entity, Entity], Related>, ): [Related, readonly TypeRelationError[]] { - if ("kind" in source && source.kind === "TemplateParameter") { + if ( + "kind" in source && + (source.kind === "TemplateParameter" || source.kind === "TemplateParameterAccess") + ) { source = source.constraint ?? checker.anyType; } if (target.entityKind === "Indeterminate") { diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 29560bd3609..51aa85780ea 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -154,6 +154,7 @@ export type Type = | StringTemplate | StringTemplateSpan | TemplateParameter + | TemplateParameterAccess | Tuple | Union | UnionVariant; @@ -713,6 +714,20 @@ export interface TemplateParameter extends BaseType { default?: Type | Value | IndeterminateEntity; } +export interface TemplateParameterAccess extends BaseType { + kind: "TemplateParameterAccess"; + /** @internal */ + node: MemberExpressionNode; + /** @internal */ + base: TemplateParameter | TemplateParameterAccess; + /** @internal */ + path: string; + /** @internal */ + cacheKey: string; + /** @internal */ + constraint?: MixedParameterConstraint; +} + export interface Decorator extends BaseType { kind: "Decorator"; node?: DecoratorDeclarationStatementNode; diff --git a/packages/compiler/src/server/type-signature.ts b/packages/compiler/src/server/type-signature.ts index 542a95c9df1..4283e749865 100644 --- a/packages/compiler/src/server/type-signature.ts +++ b/packages/compiler/src/server/type-signature.ts @@ -111,7 +111,11 @@ function getTypeSignature(type: Type, options: GetSymbolSignatureOptions): strin case "EnumMember": return `(enum member)\n${fence(getEnumMemberSignature(type))}`; case "TemplateParameter": - return `(template parameter)\n${fence(type.node.id.sv)}`; + return `(template parameter)\n${fence( + getTemplateConstraintSignature(type.node.id.sv, type.constraint), + )}`; + case "TemplateParameterAccess": + return `(template access)\n${fence(getTemplateConstraintSignature(type.path, type.constraint))}`; case "UnionVariant": return `(union variant)\n${fence(getUnionVariantSignature(type))}`; case "Tuple": @@ -146,6 +150,26 @@ function getMixedConstraintSignature( return result; } +/** Format `T extends ...` style signatures for template parameters/access paths. */ +function getTemplateConstraintSignature( + nameOrPath: string, + constraint?: MixedParameterConstraint, +): string { + if (!constraint) { + return nameOrPath; + } + + const parts: string[] = []; + if (constraint.type) { + parts.push(getTypeName(constraint.type, { printable: true })); + } + if (constraint.valueType) { + parts.push(`valueof ${getTypeName(constraint.valueType, { printable: true })}`); + } + + return parts.length > 0 ? `${nameOrPath} extends ${parts.join(" | ")}` : nameOrPath; +} + function getDecoratorSignature(type: Decorator) { const ns = getQualifier(type.namespace); const name = type.name.slice(1); diff --git a/packages/compiler/test/checker/operations.test.ts b/packages/compiler/test/checker/operations.test.ts index 2117e16f6c0..40436df45d7 100644 --- a/packages/compiler/test/checker/operations.test.ts +++ b/packages/compiler/test/checker/operations.test.ts @@ -169,6 +169,34 @@ describe("compiler: operations", () => { strictEqual(props[1].type.kind, "Scalar"); }); + it("resolves template member metaproperty parameter types when operation is instantiated", async () => { + testHost.addTypeSpecFile( + "main.tsp", + ` + model ResourceBase { + id: string; + } + + @format("uuid") + scalar uuid extends string; + + model MyResource extends ResourceBase { + id: uuid; + } + + op get(id: R.id::type): R; + + @test op myGet is get; + `, + ); + + const { myGet } = (await testHost.compile("./main.tsp")) as { myGet: Operation }; + const idParam = myGet.parameters.properties.get("id"); + ok(idParam); + strictEqual(idParam.type.kind, "Scalar"); + strictEqual((idParam.type as any).name, "uuid"); + }); + it("can reference an operation defined inside an interface", async () => { testHost.addTypeSpecFile( "main.tsp", diff --git a/packages/compiler/test/checker/references.test.ts b/packages/compiler/test/checker/references.test.ts index b721394cb63..f2a4c719bb7 100644 --- a/packages/compiler/test/checker/references.test.ts +++ b/packages/compiler/test/checker/references.test.ts @@ -782,6 +782,38 @@ describe("compiler: references", () => { `, ref: "Person.address::type.city", })); + + describe("ModelProperty::type through template parameter constrained to Reflection.ModelProperty", () => + itCanReference({ + code: ` + model Person { + address: Address + } + model Address { + @test("target") city: string + } + model Wrapper

{ + value: P::type; + } + alias Wrapped = Wrapper; + `, + ref: "Wrapped.value::type.city", + })); + + describe("ModelProperty::type through template member access constrained to a concrete model", () => + itCanReference({ + code: ` + model X { + @test("target") a: string; + } + model Y { + p: M.a::type; + } + alias YOfX = Y; + `, + ref: "YOfX.p::type", + resolveTarget: (target: any) => target.type, + })); describe("Operation::returnType", () => itCanReference({ code: ` @@ -790,6 +822,33 @@ describe("compiler: references", () => { ref: "testOp::returnType.status", })); + describe("Operation::returnType through template parameter constrained to Reflection.Operation", () => + itCanReference({ + code: ` + op testOp(): { @test("target") status: 200 }; + model ReturnWrapper { + value: T::returnType; + } + alias WrappedReturn = ReturnWrapper; + `, + ref: "WrappedReturn.value::type.status", + })); + + describe("Operation::returnType through template parameter constrained to Reflection.Operation with templated operation", () => + itCanReference({ + code: ` + model X { + @test("target") y: s; + } + op foo(): X; + model ReturnWrapper { + value: O::returnType; + } + alias WrappedReturn = ReturnWrapper>; + `, + ref: "WrappedReturn.value::type.y", + })); + describe("Operation::parameters", () => itCanReference({ code: ` @@ -798,6 +857,18 @@ describe("compiler: references", () => { ref: "testOp::parameters.select", })); + describe("Operation::parameters through template parameter constrained to Reflection.Operation", () => + itCanReference({ + code: ` + op testOp(@test("target") select: string, other: string): void; + model ParametersWrapper { + value: T::parameters; + } + alias WrappedParameters = ParametersWrapper; + `, + ref: "WrappedParameters.value::type.select", + })); + it("emits a diagnostic when referencing a non-existent meta type property", async () => { testHost.addTypeSpecFile( "main.tsp", @@ -824,6 +895,26 @@ describe("compiler: references", () => { ]); }); + it("emits a diagnostic when template access is not guaranteed by the constraint", async () => { + testHost.addTypeSpecFile( + "main.tsp", + ` + model Y { + p: M.a::type; + } + `, + ); + + const diagnostics = await testHost.diagnose("./main.tsp"); + + expectDiagnostics(diagnostics, [ + { + code: "invalid-ref", + message: `Model doesn't have member a`, + }, + ]); + }); + it("allows spreading meta type property", async () => { testHost.addTypeSpecFile( "main.tsp", diff --git a/packages/compiler/test/server/completion.test.ts b/packages/compiler/test/server/completion.test.ts index d12643b8c3b..e79b4be4103 100644 --- a/packages/compiler/test/server/completion.test.ts +++ b/packages/compiler/test/server/completion.test.ts @@ -569,6 +569,75 @@ describe("identifiers", () => { ]); }); + it("completes model members from template parameter constraints", async () => { + const completions = await complete( + ` + model X { + a: string; + b: int32; + } + model Y { + p: M.┆ + } + `, + ); + + ok(completions.items.find((item) => item.label === "a")); + ok(completions.items.find((item) => item.label === "b")); + }); + + it("completes meta property '::type' from Reflection.ModelProperty-constrained template parameters", async () => { + const completions = await complete( + ` + model Wrapper

{ + value: P::┆ + } + `, + ); + + ok(completions.items.find((item) => item.label === "type")); + }); + + it("completes operation meta properties from Reflection.Operation-constrained template parameters", async () => { + const completions = await complete( + ` + model Wrapper { + value: O::┆ + } + `, + ); + + ok(completions.items.find((item) => item.label === "parameters")); + ok(completions.items.find((item) => item.label === "returnType")); + }); + + it("does not complete model members for unconstrained template parameters", async () => { + const completions = await complete( + ` + model Wrapper { + value: M.┆ + } + `, + ); + + ok(!completions.items.find((item) => item.label === "a")); + ok(!completions.items.find((item) => item.label === "b")); + }); + + it("does not complete meta properties for unconstrained template parameters", async () => { + const completions = await complete( + ` + model Wrapper { + value: M::┆ + } + `, + ); + + ok(!completions.items.find((item) => item.label === "type")); + ok(!completions.items.find((item) => item.label === "parameters")); + ok(!completions.items.find((item) => item.label === "returnType")); + }); + it("completes partial identifiers", async () => { const completions = await complete( ` diff --git a/packages/compiler/test/server/document-highlight.test.ts b/packages/compiler/test/server/document-highlight.test.ts index 819050e30e5..cff71504c06 100644 --- a/packages/compiler/test/server/document-highlight.test.ts +++ b/packages/compiler/test/server/document-highlight.test.ts @@ -172,6 +172,46 @@ describe("compiler: server: documentHighlight", () => { ]); }); + it("includes template access references in highlighting", async () => { + const ranges = await findDocumentHighlight(` + model X { + a: string; + } + model Y { + p1: M.a┆::type; + p2: M.a::type; + }`); + + deepStrictEqual(ranges, [ + { + kind: 2, + range: { + end: { + character: 13, + line: 5, + }, + start: { + character: 12, + line: 5, + }, + }, + }, + { + kind: 2, + range: { + end: { + character: 13, + line: 6, + }, + start: { + character: 12, + line: 6, + }, + }, + }, + ]); + }); + async function findDocumentHighlight(sourceWithCursor: string): Promise { const { source, pos } = extractCursor(sourceWithCursor); const testHost = await createTestServerHost(); diff --git a/packages/compiler/test/server/get-hover.test.ts b/packages/compiler/test/server/get-hover.test.ts index faec38e3a32..7121e97e48d 100644 --- a/packages/compiler/test/server/get-hover.test.ts +++ b/packages/compiler/test/server/get-hover.test.ts @@ -1,4 +1,4 @@ -import { deepStrictEqual } from "assert"; +import { deepStrictEqual, ok } from "assert"; import { describe, it } from "vitest"; import { Hover, MarkupKind } from "vscode-languageserver/node.js"; import { extractCursor } from "../../src/testing/source-utils.js"; @@ -706,6 +706,122 @@ interface TestNs.Bird { }); }); + describe("template access", () => { + it("shows template parameter hover using extends signature", async () => { + const hover = await getHoverAtCursor(` + model X { + value: T┆; + } + `); + + const value = getHoverValue(hover); + ok(value); + ok(value.includes("(template parameter)")); + ok(value.includes("T extends string")); + }); + + it("shows template access hover with concrete constraint information", async () => { + const hover = await getHoverAtCursor( + ` + model X { + a: string; + } + model Y { + p: M.a::ty┆pe; + } + `, + ); + + const value = getHoverValue(hover); + ok(value); + ok(value.includes("(template access)")); + ok(value.includes("M.a::type extends string")); + }); + + it("shows template access hover for reflection-constrained metaproperties", async () => { + const hover = await getHoverAtCursor( + ` + model Y

{ + p: P::ty┆pe; + } + `, + ); + + const value = getHoverValue(hover); + ok(value); + ok(value.includes("(template access)")); + ok(value.includes("P::type extends unknown")); + }); + + it("keeps template access hover in template declarations with downstream instantiations", async () => { + const memberHover = await getHoverAtCursor(` + model X { + y: s; + } + + model A> { + z: M.┆y::type; + } + + op foo(): A>; + + interface Operations { + get(): O::returnType; + } + + interface Z extends Operations> {} + `); + const memberValue = getHoverValue(memberHover); + ok(memberValue); + ok(memberValue.includes("(template access)")); + ok(memberValue.includes("M.y extends X.y")); + + const metapropertyHover = await getHoverAtCursor(` + model X { + y: s; + } + + model A> { + z: M.y::ty┆pe; + } + + op foo(): A>; + + interface Operations { + get(): O::returnType; + } + + interface Z extends Operations> {} + `); + const metapropertyValue = getHoverValue(metapropertyHover); + ok(metapropertyValue); + ok(metapropertyValue.includes("(template access)")); + ok(metapropertyValue.includes("M.y::type extends string")); + + const returnTypeHover = await getHoverAtCursor(` + model X { + y: s; + } + + model A> { + z: M.y::type; + } + + op foo(): A>; + + interface Operations { + get(): O::ret┆urnType; + } + + interface Z extends Operations> {} + `); + const returnTypeValue = getHoverValue(returnTypeHover); + ok(returnTypeValue); + ok(returnTypeValue.includes("(template access)")); + ok(returnTypeValue.includes("O::returnType extends unknown")); + }); + }); + async function getHoverAtCursor(sourceWithCursor: string): Promise { const { source, pos } = extractCursor(sourceWithCursor); const testHost = await createTestServerHost(); @@ -718,4 +834,17 @@ interface TestNs.Bird { position: textDocument.positionAt(pos), }); } + + /** Normalize hover contents into a single comparable string for assertions. */ + function getHoverValue(hover: Hover | undefined): string | undefined { + if (!hover) return undefined; + const contents = hover.contents; + if (typeof contents === "string") { + return contents; + } + if (Array.isArray(contents)) { + return contents.map((x) => (typeof x === "string" ? x : x.value)).join("\n"); + } + return contents.value; + } }); diff --git a/packages/html-program-viewer/src/react/type-config.ts b/packages/html-program-viewer/src/react/type-config.ts index 8bda40b820a..1b4d10078a0 100644 --- a/packages/html-program-viewer/src/react/type-config.ts +++ b/packages/html-program-viewer/src/react/type-config.ts @@ -147,6 +147,12 @@ export const TypeConfig: TypeGraphConfig = buildConfig({ constraint: "value", default: "value", }, + TemplateParameterAccess: { + constraint: "value", + base: "skip", + path: "skip", + cacheKey: "skip", + }, // Don't want to expose those for now FunctionType: null, diff --git a/packages/http-server-csharp/src/lib/service.ts b/packages/http-server-csharp/src/lib/service.ts index 6f9b671f398..cb1e6ac5757 100644 --- a/packages/http-server-csharp/src/lib/service.ts +++ b/packages/http-server-csharp/src/lib/service.ts @@ -160,6 +160,7 @@ export async function $onEmit(context: EmitContext) case "ScalarConstructor": case "StringTemplateSpan": case "TemplateParameter": + case "TemplateParameterAccess": case "Tuple": case "FunctionType": return undefined; diff --git a/packages/tspd/src/ref-doc/utils/type-signature.ts b/packages/tspd/src/ref-doc/utils/type-signature.ts index e6d3bfec4d3..591a4b484b6 100644 --- a/packages/tspd/src/ref-doc/utils/type-signature.ts +++ b/packages/tspd/src/ref-doc/utils/type-signature.ts @@ -55,7 +55,15 @@ export function getTypeSignature(type: Type): string { case "EnumMember": return `(enum member) ${getEnumMemberSignature(type)}`; case "TemplateParameter": - return (type.node! as any).id.sv; + return getTemplateConstraintSignature( + getTypeName(type), + (type as { constraint?: TemplateConstraintLike }).constraint, + ); + case "TemplateParameterAccess": + return getTemplateConstraintSignature( + getTypeName(type), + (type as { constraint?: TemplateConstraintLike }).constraint, + ); case "UnionVariant": return `(union variant) ${getUnionVariantSignature(type)}`; case "Tuple": @@ -105,6 +113,32 @@ function getTemplateParameters(templateParameters: readonly TemplateParameterDec const params = templateParameters.map((x) => `${x.id.sv}`); return `<${params.join(", ")}>`; } + +/** Format `T extends ...` style signatures for template parameters/access paths. */ +function getTemplateConstraintSignature( + nameOrPath: string, + constraint?: TemplateConstraintLike, +): string { + if (!constraint) { + return nameOrPath; + } + + const parts: string[] = []; + if (constraint.type) { + parts.push(getTypeName(constraint.type)); + } + if (constraint.valueType) { + parts.push(`valueof ${getTypeName(constraint.valueType)}`); + } + + return parts.length > 0 ? `${nameOrPath} extends ${parts.join(" | ")}` : nameOrPath; +} + +type TemplateConstraintLike = { + type?: Type; + valueType?: Type; +}; + function getOperationSignature(type: Operation) { const qualifier = getQualifier(type.interface ?? type.namespace); const parameters = [...type.parameters.properties.values()].map(getModelPropertySignature);