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..98f7de6336c --- /dev/null +++ b/.chronus/changes/witemple-msft-templateparameter-metaproperties-2026-2-2-13-17-55.md @@ -0,0 +1,17 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Enabled resolution of member properties and metaproperties through template parameters based on constraints. + +```tsp +model Resource { + id: string; +} + +model Read { + id: R.id; +} +``` 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 66a1658d6c0..8aaf78335a1 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,10 @@ export function createChecker(program: Program, resolver: NameResolver): Checker * Tracking the template parameters used or not. */ const templateParameterUsageMap = new Map(); + const templateAccessSymbolCache = new Map(); + const templateAccessCacheKeys = new WeakMap(); + const symbolCacheIds = new WeakMap(); + let nextSymbolCacheId = 1; const checker: Checker = { getTypeForNode, @@ -747,7 +752,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( @@ -789,7 +794,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker // synthesize a template value placeholder even when the template parameter is mapped // from an outer template declaration. if ( - entity.kind === "TemplateParameter" && + (entity.kind === "TemplateParameter" || entity.kind === "TemplateParameterAccess") && entity.constraint?.valueType && entity.constraint.type === undefined ) { @@ -1220,7 +1225,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( @@ -2324,7 +2336,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") { @@ -3191,31 +3203,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 { @@ -3268,6 +3293,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; @@ -3362,7 +3496,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; @@ -3416,6 +3550,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))) { @@ -3467,7 +3606,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); @@ -3477,6 +3621,588 @@ 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 === "::" + ) { + return getPreferredTemplateAccessSymbol(node, accessedType); + } + + if (ctx.mapper !== undefined) { + return getPreferredTemplateAccessSymbol(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; + } + + return getPreferredTemplateAccessSymbol(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; + } + + /** + * Prefer canonical member symbols for resolved template access results when they already exist. + * This preserves symbol identity for downstream cloning and doc lookups in instantiated flows. + */ + function getPreferredTemplateAccessSymbol(node: MemberExpressionNode, type: Type): Sym { + const canonicalMemberSymbol = getCanonicalTemplateAccessMemberSymbol(type); + if (canonicalMemberSymbol) { + return canonicalMemberSymbol; + } + + if (!shouldUseLateBoundTemplateAccessType(type)) { + return getTypeSymbol(type) ?? createLateBoundTypeSymbol(node, type); + } + + return createLateBoundTypeSymbol(node, type); + } + + /** Return the exact instantiated member symbol for a resolved type when one exists. */ + function getCanonicalTemplateAccessMemberSymbol(type: Type): Sym | undefined { + switch (type.kind) { + case "ModelProperty": + return getExactInstantiatedMemberSymbol(type.model?.symbol, type.name, type); + case "Operation": + return getExactInstantiatedMemberSymbol(type.interface?.symbol, type.name, type); + case "EnumMember": + return getExactInstantiatedMemberSymbol(type.enum?.symbol, type.name, type); + case "UnionVariant": + return getExactInstantiatedMemberSymbol(type.union?.symbol, type.name, type); + case "ScalarConstructor": + return getExactInstantiatedMemberSymbol(type.scalar?.symbol, type.name, type); + default: + return undefined; + } + } + + function getExactInstantiatedMemberSymbol( + containerSymbol: Sym | undefined, + memberName: string | symbol, + type: Type, + ): Sym | undefined { + if (!containerSymbol || typeof memberName !== "string") { + return undefined; + } + + const memberSymbol = getMemberSymbol(containerSymbol, memberName); + return memberSymbol?.type === type ? memberSymbol : 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, + constraint, + }); + templateAccessCacheKeys.set(type, cacheKey); + + 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 { + let baseKey: string; + if (base.kind === "TemplateParameterAccess") { + const cacheKey = templateAccessCacheKeys.get(base); + compilerAssert(cacheKey, "Expected template access cache key"); + baseKey = cacheKey; + } else { + baseKey = `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; @@ -3520,7 +4246,6 @@ export function createChecker(program: Program, resolver: NameResolver): Checker ); } } - function reportAmbiguousIdentifier(node: IdentifierNode, symbols: Sym[]) { const duplicateNames = symbols.map((s) => getFullyQualifiedSymbolName(s, { useGlobalPrefixAtTopLevel: true }), @@ -3535,10 +4260,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, @@ -3623,6 +4354,88 @@ 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); + } + } + + return getPreferredTemplateAccessSymbol(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; + const symbolSource = getCanonicalTemplateAccessMemberSymbol(type); + if (symbolSource) { + mutate(symbol).symbolSource = symbolSource.symbolSource ?? symbolSource; + } + 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 @@ -3633,7 +4446,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. @@ -3706,7 +4524,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; @@ -3738,7 +4560,9 @@ export function createChecker(program: Program, resolver: NameResolver): Checker for (const [span, typeOrValue] of spanTypeOrValues) { if ( typeOrValue !== null && - (!("kind" in typeOrValue) || typeOrValue.kind !== "TemplateParameter") + (!("kind" in typeOrValue) || + (typeOrValue.kind !== "TemplateParameter" && + typeOrValue.kind !== "TemplateParameterAccess")) ) { compilerAssert(typeOrValue !== null && isValue(typeOrValue), "Expected value."); str += stringifyValueForTemplate(typeOrValue); @@ -4543,7 +5367,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( @@ -5187,7 +6011,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( @@ -5544,7 +6368,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") { @@ -5652,7 +6480,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker const shouldRunDecorators = !ctx.hasFlags(CheckFlags.InTemplateDeclaration); if (!parentTemplate || shouldRunDecorators) { - const docComment = docFromCommentForSym.get(sym); + const docComment = getDocCommentForSymbol(sym); if (docComment) { type.decorators.unshift(createDocFromCommentDecorator("self", docComment)); } @@ -5672,6 +6500,13 @@ export function createChecker(program: Program, resolver: NameResolver): Checker }; } + function getDocCommentForSymbol(sym: Sym | undefined): string | undefined { + return sym + ? (docFromCommentForSym.get(sym) ?? + (sym.symbolSource && docFromCommentForSym.get(sym.symbolSource))) + : undefined; + } + function checkDefaultValue(ctx: CheckContext, defaultNode: Node, type: Type): Value | null { if (isErrorType(type)) { // if the prop type is an error we don't need to validate again. @@ -7017,7 +7852,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker ): T { let clone = initializeClone(type, additionalProps); if ("decorators" in clone) { - const docComment = docFromCommentForSym.get(sym); + const docComment = getDocCommentForSymbol(sym); if (docComment) { clone.decorators.push(createDocFromCommentDecorator("self", docComment)); } 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..0d56166706d 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, @@ -714,10 +717,9 @@ export function createResolver(program: Program): NameResolver { function resolveMetaMemberByName(baseSym: Sym, sv: string): ResolutionResult { const baseNode = getSymNode(baseSym); + const prototype = getMetaTypePrototypeForSymbol(baseSym, baseNode); - const prototype = metaTypePrototypes.get(baseNode.kind); - - if (!prototype) { + if (!prototype || isReflectionMetaProjectionSymbol(baseSym, baseNode)) { return failedResult(ResolutionResultFlags.NotFound); } @@ -730,6 +732,47 @@ 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; + } + + /** Return true for TypeSpec.Reflection model symbols backed by projection metadata. */ + function isReflectionMetaProjectionSymbol(baseSym: Sym, baseNode: Node): boolean { + return ( + baseNode.kind === SyntaxKind.ModelStatement && + baseSym.parent?.name === "Reflection" && + baseSym.parent?.parent?.name === "TypeSpec" + ); + } + 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..c5010715442 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, @@ -384,6 +385,19 @@ function navigateTemplateParameter(type: TemplateParameter, context: NavigationC return; } if (context.emit("templateParameter", type) === ListenerFlow.NoRecursion) return; + context.emit("exitTemplateParameter", type); +} + +/** 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; + context.emit("exitTemplateParameterAccess", type); } function navigateDecoratorDeclaration(type: Decorator, context: NavigationContext) { @@ -436,6 +450,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 +515,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..ab353fdaf7f 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,42 @@ export interface TemplateParameter extends BaseType { default?: Type | Value | IndeterminateEntity; } +/** + * This is a type you should never see in the program. + * If you do you might be missing a `isTemplateDeclaration` check to exclude that type. + * Working with template declarations is not something that is currently supported. + * + * `TemplateParameterAccess` represents a member or meta-member access rooted in a template + * parameter inside a template declaration, such as `T.id` or `T::returnType`. + * + * @experimental + */ +export interface TemplateParameterAccess extends BaseType { + kind: "TemplateParameterAccess"; + /** @internal */ + node: MemberExpressionNode; + /** + * The base of this template parameter access, which could be another template parameter access for chained accesses like `T.id.name`. + * + * @internal + */ + base: TemplateParameter | TemplateParameterAccess; + /** + * User-facing access path like `T.id` or `T::returnType`. + * + * @internal + */ + path: string; + /** + * The type or value constraint of this template parameter access. + * + * The constraint is used to determine assignability in template declarations. + * + * @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 a133cfc833d..5f03c841816 100644 --- a/packages/compiler/test/checker/operations.test.ts +++ b/packages/compiler/test/checker/operations.test.ts @@ -122,6 +122,30 @@ describe("compiler: operations", () => { strictEqual(props[1].type.kind, "Scalar"); }); + it("resolves template member metaproperty parameter types when operation is instantiated", async () => { + const { myGet } = await Tester.compile(t.code` + 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 ${t.op("myGet")} is get; + `); + + const idParam = myGet.parameters.properties.get("id"); + ok(idParam); + ok(idParam.type.kind === "Scalar"); + strictEqual(idParam.type.name, "uuid"); + }); + it("can reference an operation defined inside an interface", async () => { const { newFoo } = await Tester.compile(t.code` interface Foo { diff --git a/packages/compiler/test/checker/references.test.ts b/packages/compiler/test/checker/references.test.ts index 7896c6dc19f..75a9e3298b4 100644 --- a/packages/compiler/test/checker/references.test.ts +++ b/packages/compiler/test/checker/references.test.ts @@ -1,6 +1,7 @@ /* eslint-disable vitest/valid-describe-callback */ import { ok, strictEqual } from "assert"; import { describe, it } from "vitest"; +import { getNodeAtPosition } from "../../src/core/parser.js"; import { Enum, Interface, Model, Operation, Type } from "../../src/core/types.js"; import { expectDiagnostics, expectTypeEquals, mockFile, t } from "../../src/testing/index.js"; import { Tester } from "../tester.js"; @@ -693,6 +694,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: ` @@ -701,6 +734,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: ` @@ -709,6 +769,39 @@ 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("resolves parameter members through wrappers over templated operation instances", async () => { + const result = await Tester.compile({ + "main.tsp": ` + op testOp(select: T, other: string): void; + model ParametersWrapper { + value: O::parameters; + } + alias WrappedParameters = ParametersWrapper>; + model RefContainer { y: WrappedParameters.value::type.select } + `, + }); + + const file = result.program.sourceFiles.get("/test/main.tsp")!; + const text = file.file.text; + const wrappedSelect = getNodeAtPosition(file, text.lastIndexOf("select") + 1)!; + const wrappedSymbols = result.program.checker.resolveRelatedSymbols(wrappedSelect as any); + + ok(wrappedSymbols?.[0], "Expected wrapped parameter symbol to resolve."); + strictEqual(wrappedSymbols[0].name, "select"); + }); + it("emits a diagnostic when referencing a non-existent meta type property", async () => { const diagnostics = await Tester.diagnose(` model A { @@ -730,6 +823,19 @@ describe("compiler: references", () => { ]); }); + it("emits a diagnostic when template access is not guaranteed by the constraint", async () => { + const diagnostics = await Tester.diagnose(` + model Y { + p: M.a::type; + } + `); + + expectDiagnostics(diagnostics, { + code: "invalid-ref", + message: `Model doesn't have member a`, + }); + }); + it("allows spreading meta type property", async () => { const { Spread } = await Tester.compile(t.code` model A { diff --git a/packages/compiler/test/checker/string-template.test.ts b/packages/compiler/test/checker/string-template.test.ts index ffefac7f517..fb8e5247417 100644 --- a/packages/compiler/test/checker/string-template.test.ts +++ b/packages/compiler/test/checker/string-template.test.ts @@ -142,3 +142,25 @@ it("emit error if interpolating template parameter that is a value but using tem end, }); }); + +it("emit error if interpolating template access that mixes values and types", async () => { + const { source, pos, end } = extractSquiggles(` + const prefix = "value"; + + model Input { + prop: string; + } + + alias Template = { + a: ~~~"\${prefix} \${T.prop::type}"~~~; + }; + `); + const diagnostics = await Tester.diagnose(source); + expectDiagnostics(diagnostics, { + code: "mixed-string-template", + message: + "String template is interpolating values and types. It must be either all values to produce a string value or or all types for string template type.", + pos, + end, + }); +}); diff --git a/packages/compiler/test/name-resolver.test.ts b/packages/compiler/test/name-resolver.test.ts index 24fca9bdb45..5feb0c9a647 100644 --- a/packages/compiler/test/name-resolver.test.ts +++ b/packages/compiler/test/name-resolver.test.ts @@ -784,6 +784,52 @@ describe("operations", () => { }); }); +describe("meta-member helper APIs", () => { + it("list Reflection meta-members without resolving projection-backed symbols directly", () => { + const { + ReflectionModelProperty: reflectionModelProperty, + ReflectionOperation: reflectionOperation, + } = getResolutions( + [ + ` + namespace TypeSpec.Reflection { + model ModelProperty {} + model Operation {} + } + + alias ReflectionModelProperty = TypeSpec.Reflection.ModelProperty; + alias ReflectionOperation = TypeSpec.Reflection.Operation; + `, + ], + "ReflectionModelProperty", + "ReflectionOperation", + ); + + ok(reflectionModelProperty.finalSymbol); + ok(reflectionOperation.finalSymbol); + + ok(resolver.getMetaMemberNames(reflectionModelProperty.finalSymbol).includes("type")); + ok(resolver.getMetaMemberNames(reflectionOperation.finalSymbol).includes("parameters")); + ok(resolver.getMetaMemberNames(reflectionOperation.finalSymbol).includes("returnType")); + + strictEqual( + resolver.resolveMetaMemberByName(reflectionModelProperty.finalSymbol, "type") + .resolutionResult, + ResolutionResultFlags.NotFound, + ); + strictEqual( + resolver.resolveMetaMemberByName(reflectionOperation.finalSymbol, "parameters") + .resolutionResult, + ResolutionResultFlags.NotFound, + ); + strictEqual( + resolver.resolveMetaMemberByName(reflectionOperation.finalSymbol, "returnType") + .resolutionResult, + ResolutionResultFlags.NotFound, + ); + }); +}); + describe("accessing non members resolve to NotFound", () => { it("accessing property on ModelProperty", () => { const { "Foo.bar.doesNotExists": x } = getResolutions( diff --git a/packages/compiler/test/semantic-walker.test.ts b/packages/compiler/test/semantic-walker.test.ts index 88588deec44..50176edf7fe 100644 --- a/packages/compiler/test/semantic-walker.test.ts +++ b/packages/compiler/test/semantic-walker.test.ts @@ -7,7 +7,7 @@ import { navigateType, navigateTypesInNamespace, } from "../src/core/semantic-walker.js"; -import { FunctionValue } from "../src/core/types.js"; +import { FunctionValue, TemplateParameter, TemplateParameterAccess } from "../src/core/types.js"; import { Enum, Interface, @@ -707,5 +707,55 @@ describe("compiler: semantic walker", () => { expect(results.functions).toHaveLength(1); expect(results.functions[0].name).toBe("foo"); }); + + it("emits exit events for template parameter access types", () => { + const templateParameter: TemplateParameter = { + entityKind: "Type", + kind: "TemplateParameter", + node: undefined as never, + isFinished: true, + }; + const templateAccess: TemplateParameterAccess = { + entityKind: "Type", + kind: "TemplateParameterAccess", + node: undefined as never, + base: templateParameter, + path: "T.id", + isFinished: true, + }; + + const events: string[] = []; + navigateType( + templateParameter, + { + templateParameter() { + events.push("templateParameter"); + }, + exitTemplateParameter() { + events.push("exitTemplateParameter"); + }, + }, + {}, + ); + navigateType( + templateAccess, + { + templateParameterAccess() { + events.push("templateParameterAccess"); + }, + exitTemplateParameterAccess() { + events.push("exitTemplateParameterAccess"); + }, + }, + {}, + ); + + deepStrictEqual(events, [ + "templateParameter", + "exitTemplateParameter", + "templateParameterAccess", + "exitTemplateParameterAccess", + ]); + }); }); }); diff --git a/packages/compiler/test/server/completion.test.ts b/packages/compiler/test/server/completion.test.ts index 26f68319109..056ccf008ea 100644 --- a/packages/compiler/test/server/completion.test.ts +++ b/packages/compiler/test/server/completion.test.ts @@ -623,6 +623,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 efa2d09a9c3..608ddaf470b 100644 --- a/packages/http-server-csharp/src/lib/service.ts +++ b/packages/http-server-csharp/src/lib/service.ts @@ -161,6 +161,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);