diff --git a/src/frontend/src/components/ApiReference.astro b/src/frontend/src/components/ApiReference.astro index 411df3d5e..9781ddca9 100644 --- a/src/frontend/src/components/ApiReference.astro +++ b/src/frontend/src/components/ApiReference.astro @@ -30,76 +30,54 @@ function formatTitle(title: string | undefined): string | undefined { const { name, package: packageName, parameterTypes } = Astro.props; const resolution = await resolveApiReference(name, packageName, Astro.locals, parameterTypes); const base = import.meta.env.BASE_URL.replace(/\/$/, ''); -const csharpHref = resolution.csharp.path ? `${base}${resolution.csharp.path}` : undefined; -const typescriptHref = resolution.typescript.path - ? `${base}${resolution.typescript.path}` - : undefined; const diagnosticText = resolution.diagnostics.map((diagnostic) => diagnostic.message).join(' '); -const csharpDiagnostic = resolution.status === 'resolved' ? undefined : diagnosticText; -const typescriptDiagnostic = resolution.diagnostics.length > 0 ? diagnosticText : undefined; +const languages: Array<{ id: string; icon: string; displayName: string }> = [ + { id: 'csharp', icon: 'i-material-icon-theme:csharp', displayName: 'C#' }, + { id: 'typescript', icon: 'i-material-icon-theme:typescript', displayName: 'TypeScript' }, +]; --- { - csharpHref ? ( - - - - - ) : ( - - {resolution.csharp.label} - - ) - } - { - typescriptHref ? ( - - - - - ) : ( - - {resolution.typescript.label} - - ) + languages.map(({ id, icon, displayName }) => { + const target = + resolution.targets?.[id] ?? (id === 'csharp' ? resolution.csharp : resolution.typescript); + const href = target.path ? `${base}${target.path}` : undefined; + const diagnostic = href + ? undefined + : resolution.status === 'resolved' && resolution.diagnostics.length === 0 + ? undefined + : diagnosticText; + + return href ? ( + + + + + ) : ( + + {target.label} + + ); + }) } @@ -112,11 +90,12 @@ const typescriptDiagnostic = resolution.diagnostics.length > 0 ? diagnosticText display: none; } + :global(html[data-apphost-lang='csharp']) .ar-lang[data-lang='csharp'], :global(html[data-apphost-lang='typescript']) .ar-lang[data-lang='typescript'] { display: inline; } - :global(html:not([data-apphost-lang='typescript'])) .ar-lang[data-lang='csharp'] { + :global(html:not([data-apphost-lang])) .ar-lang[data-lang='csharp'] { display: inline; } diff --git a/src/frontend/src/utils/api-reference-core.ts b/src/frontend/src/utils/api-reference-core.ts index fef0e56e6..1db096e17 100644 --- a/src/frontend/src/utils/api-reference-core.ts +++ b/src/frontend/src/utils/api-reference-core.ts @@ -1,12 +1,7 @@ -import { memberNameSlug, resolveMemberAnchorMap } from './api-member-anchors'; -import { sampleDescriptionText } from './samples'; -import { - getTsItemSlug, - getTsMemberAnchor, - getTsMethodSlug, - getTsTopLevelRouteItems, - type TsRouteParameterLike, -} from './ts-api-routes'; +import type { TsRouteParameterLike } from './ts-api-routes'; +import { CSharpLanguageProvider } from './api-reference/csharp-provider'; +import { ApiReferenceProviderRegistry } from './api-reference/registry'; +import { TypeScriptLanguageProvider } from './api-reference/typescript-provider'; export interface ApiReferenceAttribute { name: string; @@ -30,10 +25,10 @@ export interface ApiReferenceMember { attributes?: ApiReferenceAttribute[]; isStatic?: boolean; isExtension?: boolean; - docs?: { summary?: string | ApiReferenceDocNode[] }; + docs?: { summary?: unknown }; } -interface ApiReferenceDocNode { +export interface ApiReferenceDocNode { kind: string; text?: string; value?: string; @@ -112,9 +107,11 @@ export interface ApiReferenceDiagnostic { export interface ApiReferenceResolution { name: string; status: 'resolved' | 'missing' | 'ambiguous'; + primaryLanguage?: string; + targets?: Record; + diagnostics: ApiReferenceDiagnostic[]; csharp: ApiReferenceTarget; typescript: ApiReferenceTarget; - diagnostics: ApiReferenceDiagnostic[]; } export interface ApiReferenceIndex { @@ -126,840 +123,16 @@ export interface ApiReferenceIndex { ): ApiReferenceResolution; } -interface CSharpCandidate { - fqn: string; - packageName: string; - type: ApiReferenceType; - members: ApiReferenceMember[]; -} - -interface TsRouteCandidate { - moduleName: string; - name: string; - kind?: string; - capabilityId?: string; - qualifiedName?: string; - targetTypeId?: string; - expandedTargetTypes: string[]; - parentTypeFullName?: string; - path: string; - priority: number; - parameters?: ApiReferenceTsCallable['parameters']; - description?: string; -} - -interface TsRouteIndex { - byCapabilityId: Map; - byModuleAndName: Map; - byName: Map; -} - -interface ExportMapping { - capabilityId?: string; - methodName: string; - member: ApiReferenceMember; - required: boolean; - allowUntargeted: boolean; -} - export const API_REFERENCE_FQN_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+$/; -const ASPIRE_EXPORT_ATTRIBUTE = /(?:^|\.)AspireExportAttribute$/; -const ASPIRE_EXPORT_IGNORE_ATTRIBUTE = /(?:^|\.)AspireExportIgnoreAttribute$/; -const CALLABLE_KINDS = new Set(['method', 'constructor', 'Method', 'InstanceMethod']); -const MEMBER_KIND_SLUGS: Record = { - constructor: 'constructors', - property: 'properties', - method: 'methods', - field: 'fields', - event: 'events', - indexer: 'indexers', -}; - -function addToIndex(index: Map, key: string | undefined, value: T): void { - if (!key) return; - const values = index.get(key) ?? []; - values.push(value); - index.set(key, values); -} - -function genericArity(type: ApiReferenceType): number { - return type.genericParameters?.length ?? 0; -} - -function csharpTypePath(packageName: string, typeName: string, arity: number): string { - const typeSlug = `${typeName.toLowerCase()}${arity > 0 ? `-${arity}` : ''}`; - return `/reference/api/csharp/${packageName.toLowerCase()}/${typeSlug}/`; -} - -function tsModuleSlug(name: string): string { - return name.toLowerCase(); -} - -function normalizeTypeName(value: string): string { - const withoutAssembly = value.includes('/') ? value.slice(value.indexOf('/') + 1) : value; - return withoutAssembly - .trim() - .replace(/\?$/, '') - .replace(/^global::/, '') - .replace(/`\d+/g, '') - .replace(/<.*>$/, '') - .replace(/\[\[.*\]\]$/, ''); -} - -function genericArguments(value: string): string[] { - const start = value.indexOf('<'); - if (start < 0) return []; - - const argumentsList: string[] = []; - let depth = 0; - let current = ''; - - for (let index = start + 1; index < value.length; index++) { - const character = value[index]; - if (character === '<') { - depth++; - current += character; - } else if (character === '>') { - if (depth === 0) { - if (current.trim()) argumentsList.push(current.trim()); - break; - } - depth--; - current += character; - } else if (character === ',' && depth === 0) { - argumentsList.push(current.trim()); - current = ''; - } else { - current += character; - } - } - - return argumentsList; -} - -function simpleTypeName(value: string): string { - const normalized = normalizeTypeName(value); - return normalized.slice(normalized.lastIndexOf('.') + 1); -} - -function lowerCamelCase(value: string): string { - return value ? value.charAt(0).toLowerCase() + value.slice(1) : value; -} - -function isCallable(kind: string | undefined): boolean { - return kind ? CALLABLE_KINDS.has(kind) : false; -} - -function readNamedString(attribute: ApiReferenceAttribute, name: string): string | undefined { - const value = attribute.arguments?.[name] ?? attribute.namedArguments?.[name]; - return typeof value === 'string' && value ? value : undefined; -} - -function readNamedBoolean(attribute: ApiReferenceAttribute, name: string): boolean { - const value = attribute.arguments?.[name] ?? attribute.namedArguments?.[name]; - return value === true || (typeof value === 'string' && value.toLowerCase() === 'true'); -} - -function extensionMappingIdentity(member: ApiReferenceMember): string { - if (!member.isExtension) return ''; - const receiver = - member.parameters?.find((parameter) => parameter.modifier === 'this')?.type ?? ''; - const constraints = (member.genericParameters ?? []) - .flatMap((parameter) => parameter.constraints ?? []) - .join(','); - return `${receiver}\0${constraints}`; -} - -function getExportMappings(candidate: CSharpCandidate): ExportMapping[] { - const mappings = new Map(); - const typeExport = (candidate.type.attributes ?? []).find((attribute) => - ASPIRE_EXPORT_ATTRIBUTE.test(attribute.name) - ); - const exposeMethods = typeExport ? readNamedBoolean(typeExport, 'ExposeMethods') : false; - const exposeProperties = typeExport ? readNamedBoolean(typeExport, 'ExposeProperties') : false; - - for (const member of candidate.members) { - const attributes = (member.attributes ?? []).filter((attribute) => - ASPIRE_EXPORT_ATTRIBUTE.test(attribute.name) - ); - - if (attributes.length > 0) { - for (const attribute of attributes) { - const explicitId = attribute.constructorArguments?.[0]; - const capabilityId = - typeof explicitId === 'string' && explicitId - ? `${candidate.packageName}/${explicitId}` - : member.isStatic || member.isExtension - ? `${candidate.packageName}/${lowerCamelCase(member.name)}` - : undefined; - const methodName = readNamedString(attribute, 'MethodName') ?? lowerCamelCase(member.name); - const key = `${capabilityId ?? ''}\0${methodName}\0${extensionMappingIdentity(member)}`; - mappings.set(key, { - capabilityId, - methodName, - member, - required: true, - allowUntargeted: false, - }); - } - continue; - } - - const ignoredAttributes = (member.attributes ?? []).filter((attribute) => - ASPIRE_EXPORT_IGNORE_ATTRIBUTE.test(attribute.name) - ); - if (ignoredAttributes.length > 0) { - const methodName = lowerCamelCase(member.name); - const allowUntargeted = ignoredAttributes.some((attribute) => - /\bdispatcher\b|\b(?:canonical|generic)\b[^.]*\bexport\b/i.test( - readNamedString(attribute, 'Reason') ?? '' - ) - ); - const key = `optional\0${methodName}\0${allowUntargeted}\0${extensionMappingIdentity(member)}`; - mappings.set(key, { - methodName, - member, - required: false, - allowUntargeted, - }); - continue; - } - - const exposedByType = - (member.kind === 'method' && exposeMethods) || - (member.kind === 'property' && exposeProperties); - if (exposedByType) { - const methodName = lowerCamelCase(member.name); - const key = `type\0${methodName}\0${extensionMappingIdentity(member)}`; - mappings.set(key, { - methodName, - member, - required: false, - allowUntargeted: false, - }); - } - } - - return [...mappings.values()]; -} - -function buildTsRouteIndex(modules: readonly ApiReferenceTsDocument[]): TsRouteIndex { - const byCapabilityId = new Map(); - const byModuleAndName = new Map(); - const byName = new Map(); - - const register = (candidate: TsRouteCandidate) => { - addToIndex(byCapabilityId, candidate.capabilityId, candidate); - addToIndex(byName, candidate.name.toLowerCase(), candidate); - addToIndex( - byModuleAndName, - `${candidate.moduleName}\0${candidate.name.toLowerCase()}`, - candidate - ); - }; - - for (const module of modules) { - const moduleName = module.package.name; - const modulePath = `/reference/api/typescript/${tsModuleSlug(moduleName)}`; - const topLevelItems = getTsTopLevelRouteItems(module); - - const standaloneFunctions = (module.functions ?? []).filter( - (fn) => !fn.qualifiedName || !fn.qualifiedName.includes('.') - ); - for (const fn of standaloneFunctions) { - register({ - moduleName, - name: fn.name, - kind: fn.kind, - capabilityId: fn.capabilityId, - qualifiedName: fn.qualifiedName, - targetTypeId: fn.targetTypeId, - expandedTargetTypes: fn.expandedTargetTypes ?? [], - path: `${modulePath}/${getTsItemSlug(fn, topLevelItems)}/`, - priority: 0, - parameters: fn.parameters, - description: fn.description, - }); - } - - for (const handle of module.handleTypes ?? []) { - const itemSlug = getTsItemSlug(handle, topLevelItems); - const methods = (handle.capabilities ?? []).filter( - (capability) => capability.kind === 'Method' || capability.kind === 'InstanceMethod' - ); - - for (const capability of handle.capabilities ?? []) { - let path: string | undefined; - let priority = 1; - - if (capability.kind === 'Method' || capability.kind === 'InstanceMethod') { - path = `${modulePath}/${itemSlug}/${getTsMethodSlug(capability, methods, handle.name)}/`; - } else if (capability.kind === 'PropertyGetter' || capability.kind === 'PropertySetter') { - path = `${modulePath}/${itemSlug}/#${getTsMemberAnchor(capability.name)}`; - priority = capability.kind === 'PropertyGetter' ? 1 : 2; - } - - if (!path) continue; - - register({ - moduleName, - name: capability.name, - kind: capability.kind, - capabilityId: capability.capabilityId, - qualifiedName: capability.qualifiedName, - targetTypeId: capability.targetTypeId, - expandedTargetTypes: capability.expandedTargetTypes ?? [], - parentTypeFullName: handle.fullName, - path, - priority, - parameters: capability.parameters, - description: capability.description, - }); - } - } - } - - return { byCapabilityId, byModuleAndName, byName }; -} - -function extensionReceiverTypes(candidate: CSharpCandidate, member: ApiReferenceMember): string[] { - if (!member.isExtension) return []; - const receiver = member.parameters?.find((parameter) => parameter.modifier === 'this'); - if (!receiver) return []; - - const genericParameters = [ - ...(candidate.type.genericParameters ?? []), - ...(member.genericParameters ?? []), - ]; - const receiverTypes = [receiver.type, ...genericArguments(receiver.type)]; - const resolved = new Set(); - - for (const receiverType of receiverTypes) { - const normalized = normalizeTypeName(receiverType); - const genericParameter = genericParameters.find((parameter) => parameter.name === normalized); - if (genericParameter?.constraints?.length) { - for (const constraint of genericParameter.constraints) { - resolved.add(normalizeTypeName(constraint)); - } - } else { - resolved.add(normalized); - } - } - - return [...resolved]; -} - -function candidateMatchesMember( - route: TsRouteCandidate, - candidate: CSharpCandidate, - member: ApiReferenceMember -): boolean { - const declaringFullName = normalizeTypeName( - candidate.type.fullName ?? - `${candidate.type.namespace ? `${candidate.type.namespace}.` : ''}${candidate.type.name}` - ); - const expectedTypes = member.isExtension - ? extensionReceiverTypes(candidate, member) - : [declaringFullName]; - if (expectedTypes.length === 0) expectedTypes.push(declaringFullName); - const normalizedExpectedTypes = expectedTypes.map(normalizeTypeName); - const expectedSimpleNames = normalizedExpectedTypes.map(simpleTypeName); - const targetTypes = [ - route.parentTypeFullName, - route.targetTypeId, - ...route.expandedTargetTypes, - ].filter((value): value is string => Boolean(value)); - - if ( - targetTypes.some((targetType) => - normalizedExpectedTypes.includes(normalizeTypeName(targetType)) - ) - ) { - return true; - } - - const qualifiedName = route.qualifiedName; - const lastDot = qualifiedName?.lastIndexOf('.') ?? -1; - return ( - lastDot > 0 && expectedSimpleNames.includes(simpleTypeName(qualifiedName!.slice(0, lastDot))) - ); -} - -function deduplicateTsCandidates(candidates: readonly TsRouteCandidate[]): TsRouteCandidate[] { - const unique = new Map(); - for (const candidate of candidates) { - const key = `${candidate.path}\0${candidate.name}\0${candidate.kind ?? ''}`; - const current = unique.get(key); - if (!current || candidate.priority < current.priority) { - unique.set(key, candidate); - } - } - return [...unique.values()]; -} - -function preferredTsCandidates(candidates: readonly TsRouteCandidate[]): TsRouteCandidate[] { - const unique = deduplicateTsCandidates(candidates); - if (unique.length <= 1) return unique; - const priority = Math.min(...unique.map((candidate) => candidate.priority)); - return unique.filter((candidate) => candidate.priority === priority); -} - -function describeTsCandidate(candidate: TsRouteCandidate): string { - return `${candidate.moduleName}:${candidate.qualifiedName ?? candidate.name} (${candidate.path})`; -} - -function findTsCandidates( - mapping: ExportMapping, - candidate: CSharpCandidate, - tsIndex: TsRouteIndex -): { matches: TsRouteCandidate[]; suggestions: TsRouteCandidate[] } { - if (mapping.capabilityId) { - const exact = tsIndex.byCapabilityId.get(mapping.capabilityId) ?? []; - if (exact.length > 0) { - return { matches: preferredTsCandidates(exact), suggestions: exact }; - } - } - - const named = - tsIndex.byModuleAndName.get(`${candidate.packageName}\0${mapping.methodName.toLowerCase()}`) ?? - []; - const targeted = named.filter((route) => - candidateMatchesMember(route, candidate, mapping.member) - ); - - if (targeted.length > 0) { - return { matches: preferredTsCandidates(targeted), suggestions: named }; - } - - if (mapping.allowUntargeted) { - const globalNamed = tsIndex.byName.get(mapping.methodName.toLowerCase()) ?? []; - const globalTargeted = globalNamed.filter((route) => - candidateMatchesMember(route, candidate, mapping.member) - ); - if (globalTargeted.length > 0) { - return { - matches: preferredTsCandidates(globalTargeted), - suggestions: globalNamed, - }; - } - return { matches: preferredTsCandidates(named), suggestions: named }; - } - - return { matches: [], suggestions: named }; -} - -function summaryText(summary: string | ApiReferenceDocNode[] | undefined): string | undefined { - if (typeof summary === 'string') { - return sampleDescriptionText(summary)?.replace(/\s+/g, ' ') || undefined; - } - let text = ''; - for (const node of summary ?? []) { - const value = node.children - ? (summaryText(node.children) ?? '') - : (node.text ?? node.value ?? ''); - const label = - node.kind === 'cref' - ? value - .replace(/^[A-Z]:/, '') - .replace(/\(.*$/, '') - .replace(/``?\d+/g, '') - : value; - if (text && label && !/\s$/.test(text) && !/^[\s,.:;!?)}\]]/.test(label)) text += ' '; - text += label; - } - return text.replace(/\s+/g, ' ').trim() || undefined; -} - -function createCSharpTarget(candidate: CSharpCandidate, exactOverload = false): ApiReferenceTarget { - const member = candidate.members[0]; - const kind = member.kind ?? 'method'; - const anchor = exactOverload - ? resolveMemberAnchorMap(candidate.type.members ?? []).get(member)!.exact - : memberNameSlug(member); - const parameters = (member.parameters ?? []) - .filter((parameter) => !member.isExtension || parameter.modifier !== 'this') - .map( - (parameter) => - `${parameter.modifier ? `${parameter.modifier} ` : ''}${parameter.type.replace(/\b(?:[A-Za-z_]\w*\.)+/g, '')}${parameter.name ? ` ${parameter.name}` : ''}` - ); - return { - label: exactOverload ? `${member.name}(${parameters.join(', ')})` : member.name, - description: summaryText(member.docs?.summary), - path: `${csharpTypePath( - candidate.packageName, - candidate.type.name, - genericArity(candidate.type) - )}${MEMBER_KIND_SLUGS[kind] ?? `${kind}s`}/#${anchor}`, - }; -} - -function resolveTypescriptTarget( - candidate: CSharpCandidate, - csharp: ApiReferenceTarget, - tsIndex: TsRouteIndex, - exactOverload = false -): { target: ApiReferenceTarget; diagnostics: ApiReferenceDiagnostic[] } { - const mappings = getExportMappings(candidate); - if (mappings.length === 0) { - return { - target: { label: csharp.label }, - diagnostics: [ - { - code: 'missing-typescript', - severity: 'warning', - message: `ApiReference: "${candidate.fqn}" has no TypeScript export; the C# API name is shown without a TypeScript link.`, - candidates: [], - }, - ], - }; - } - - const matches: TsRouteCandidate[] = []; - const suggestions: TsRouteCandidate[] = []; - let unresolved = false; - - for (const mapping of mappings) { - const result = findTsCandidates(mapping, candidate, tsIndex); - suggestions.push(...result.suggestions); - if (result.matches.length === 0) { - unresolved ||= mapping.required; - continue; - } - matches.push(...result.matches); - } - - const canonicalName = lowerCamelCase(candidate.members[0].name).toLowerCase(); - const canonicalMatches = matches.filter((match) => match.name.toLowerCase() === canonicalName); - const preferred = preferredTsCandidates(canonicalMatches.length > 0 ? canonicalMatches : matches); - const candidateDescriptions = deduplicateTsCandidates( - suggestions.length > 0 ? suggestions : matches - ) - .map(describeTsCandidate) - .sort(); - - if (unresolved) { - return { - target: { label: csharp.label }, - diagnostics: [ - { - code: 'unresolved-typescript-export', - severity: 'error', - message: `ApiReference: "${candidate.fqn}" declares a TypeScript export, but no generated TypeScript API matched it.`, - candidates: candidateDescriptions, - }, - ], - }; - } - - if (preferred.length === 0) { - return { - target: { label: csharp.label }, - diagnostics: [ - { - code: 'missing-typescript', - severity: 'warning', - message: `ApiReference: "${candidate.fqn}" has no TypeScript export; the C# API name is shown without a TypeScript link.`, - candidates: candidateDescriptions, - }, - ], - }; - } - - if (preferred.length > 1) { - return { - target: { label: csharp.label }, - diagnostics: [ - { - code: 'ambiguous-typescript', - severity: 'error', - message: `ApiReference: "${candidate.fqn}" maps to multiple generated TypeScript APIs.`, - candidates: preferred.map(describeTsCandidate).sort(), - }, - ], - }; - } - - const match = preferred[0]; - return { - target: { - description: - sampleDescriptionText(match.description ?? null)?.replace(/\s+/g, ' ') || undefined, - label: - exactOverload && isCallable(match.kind) - ? `${match.name}(${(match.parameters ?? []) - .map((parameter) => { - const type = parameter.callbackSignature ?? parameter.type; - return parameter.name - ? `${parameter.name}${parameter.isOptional ? '?' : ''}${type ? `: ${type}` : ''}` - : type; - }) - .join(', ')})` - : match.name, - path: match.path, - }, - diagnostics: [], - }; -} - -function describeCSharpCandidate(candidate: CSharpCandidate): string { - const signatures = candidate.members - .map((member) => member.signature) - .filter((signature): signature is string => Boolean(signature)); - return signatures.length > 0 - ? `${candidate.packageName}: ${signatures.join(' | ')}` - : `${candidate.packageName}: ${candidate.fqn}`; -} - -function editDistance(left: string, right: string): number { - const previous = Array.from({ length: right.length + 1 }, (_, index) => index); - const current = new Array(right.length + 1); - - for (let leftIndex = 1; leftIndex <= left.length; leftIndex++) { - current[0] = leftIndex; - for (let rightIndex = 1; rightIndex <= right.length; rightIndex++) { - current[rightIndex] = Math.min( - current[rightIndex - 1] + 1, - previous[rightIndex] + 1, - previous[rightIndex - 1] + (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1) - ); - } - for (let index = 0; index < current.length; index++) { - previous[index] = current[index]; - } - } - - return previous[right.length]; -} - -function buildSuggestions( - name: string, - fqnsByMemberName: ReadonlyMap -): string[] { - const memberName = name.slice(name.lastIndexOf('.') + 1).toLowerCase(); - const exact = fqnsByMemberName.get(memberName); - if (exact?.length) return [...exact].slice(0, 8); - - return [...fqnsByMemberName.entries()] - .map(([candidateName, fqns]) => ({ - distance: editDistance(memberName, candidateName), - fqns, - })) - .sort((left, right) => left.distance - right.distance) - .filter(({ distance }) => distance <= Math.max(2, Math.floor(memberName.length / 3))) - .flatMap(({ fqns }) => fqns) - .slice(0, 8); -} - -function fallbackTarget(name: string): ApiReferenceTarget { - const label = name.slice(name.lastIndexOf('.') + 1) || name || 'Unknown API'; - return { label }; -} - export function buildApiReferenceIndex( packages: readonly ApiReferencePackageDocument[], modules: readonly ApiReferenceTsDocument[] ): ApiReferenceIndex { - const candidates = new Map>(); - const fqnsByMemberName = new Map(); - const tsIndex = buildTsRouteIndex(modules); - - for (const pkg of packages) { - for (const type of pkg.types ?? []) { - const typeFullName = normalizeTypeName( - type.fullName ?? `${type.namespace ? `${type.namespace}.` : ''}${type.name}` - ); - - for (const member of type.members ?? []) { - const fqn = `${typeFullName}.${member.name}`; - const groupKey = `${pkg.package.name}\0${type.fullName ?? typeFullName}`; - const groups = candidates.get(fqn) ?? new Map(); - const group = groups.get(groupKey) ?? { - fqn, - packageName: pkg.package.name, - type, - members: [], - }; - group.members.push(member); - groups.set(groupKey, group); - candidates.set(fqn, groups); - - const memberName = member.name.toLowerCase(); - const memberFqns = fqnsByMemberName.get(memberName) ?? []; - if (!memberFqns.includes(fqn)) memberFqns.push(fqn); - fqnsByMemberName.set(memberName, memberFqns); - } - } - } - - const resolutions = new Map(); - const packageResolutions = new Map(); - - for (const [fqn, groups] of candidates) { - const matches = [...groups.values()]; - const matchesByPackage = new Map(); - for (const match of matches) { - const packageMatches = matchesByPackage.get(match.packageName) ?? []; - packageMatches.push(match); - matchesByPackage.set(match.packageName, packageMatches); - } - - for (const [packageName, packageMatches] of matchesByPackage) { - if (packageMatches.length > 1) { - const fallback = fallbackTarget(fqn); - packageResolutions.set(`${packageName}\0${fqn}`, { - name: fqn, - status: 'ambiguous', - csharp: fallback, - typescript: fallback, - diagnostics: [ - { - code: 'ambiguous-csharp', - severity: 'error', - message: `ApiReference: "${fqn}" resolves to multiple generated C# API members in package "${packageName}".`, - candidates: packageMatches.map(describeCSharpCandidate).sort(), - }, - ], - }); - continue; - } - - const match = packageMatches[0]; - const csharp = createCSharpTarget(match); - const typescript = resolveTypescriptTarget(match, csharp, tsIndex); - packageResolutions.set(`${packageName}\0${fqn}`, { - name: fqn, - status: 'resolved', - csharp, - typescript: typescript.target, - diagnostics: typescript.diagnostics, - }); - } - - if (matches.length > 1) { - const fallback = fallbackTarget(fqn); - resolutions.set(fqn, { - name: fqn, - status: 'ambiguous', - csharp: fallback, - typescript: fallback, - diagnostics: [ - { - code: 'ambiguous-csharp', - severity: 'error', - message: `ApiReference: "${fqn}" resolves to multiple generated C# API members.`, - candidates: matches.map(describeCSharpCandidate).sort(), - }, - ], - }); - continue; - } - - resolutions.set(fqn, packageResolutions.get(`${matches[0].packageName}\0${fqn}`)!); - } - - const missingResolutions = new Map(); - const overloadResolutions = new Map(); - - return { - size: packageResolutions.size, - resolve( - name: string, - packageName?: string, - parameterTypes?: readonly string[] - ): ApiReferenceResolution { - if (parameterTypes !== undefined) { - const cacheKey = JSON.stringify([name, packageName, parameterTypes]); - const cached = overloadResolutions.get(cacheKey); - if (cached) return cached; - - const groups = [...(candidates.get(name)?.values() ?? [])].filter( - (candidate) => !packageName || candidate.packageName === packageName - ); - const matches = groups.flatMap((candidate) => - candidate.members - .filter( - (member) => - isCallable(member.kind) && - (member.parameters ?? []).length === parameterTypes.length && - (member.parameters ?? []).every( - (parameter, index) => parameter.type === parameterTypes[index] - ) - ) - .map((member) => ({ ...candidate, members: [member] })) - ); - let resolution: ApiReferenceResolution; - if (matches.length === 1) { - const candidate = matches[0]; - const csharp = createCSharpTarget(candidate, true); - const typescript = resolveTypescriptTarget(candidate, csharp, tsIndex, true); - resolution = { - name, - status: 'resolved', - csharp, - typescript: typescript.target, - diagnostics: typescript.diagnostics, - }; - } else { - const fallback = fallbackTarget(name); - resolution = { - name, - status: matches.length > 1 ? 'ambiguous' : 'missing', - csharp: fallback, - typescript: fallback, - diagnostics: [ - { - code: matches.length > 1 ? 'ambiguous-overload' : 'missing-overload', - severity: 'error', - message: `ApiReference: "${name}" with parameter types ${JSON.stringify(parameterTypes)} ${matches.length > 1 ? 'matches multiple overloads' : 'does not match a generated overload'}. Use the complete declared C# parameter types, including the extension receiver, and qualify the package if needed.`, - candidates: (matches.length > 1 ? matches : groups) - .map(describeCSharpCandidate) - .sort(), - }, - ], - }; - } - overloadResolutions.set(cacheKey, resolution); - return resolution; - } - const resolved = packageName - ? packageResolutions.get(`${packageName}\0${name}`) - : resolutions.get(name); - if (resolved) return resolved; - - const cacheKey = `${packageName ?? ''}\0${name}`; - const cached = missingResolutions.get(cacheKey); - if (cached) return cached; - - const validFqn = API_REFERENCE_FQN_PATTERN.test(name); - const fallback = fallbackTarget(name); - const packageCandidates = candidates.get(name); - const candidatesForDiagnostic = packageCandidates - ? [...packageCandidates.values()].map(describeCSharpCandidate).sort() - : validFqn - ? buildSuggestions(name, fqnsByMemberName) - : []; - const missing: ApiReferenceResolution = { - name, - status: 'missing', - csharp: fallback, - typescript: fallback, - diagnostics: [ - { - code: validFqn ? 'missing-csharp' : 'invalid-fqn', - severity: 'error', - message: - validFqn && packageName - ? `ApiReference: could not resolve "${name}" in package "${packageName}".` - : validFqn - ? `ApiReference: could not resolve "${name}" to a generated C# API member.` - : `ApiReference: "${name}" is not a canonical fully qualified API member name.`, - candidates: candidatesForDiagnostic, - }, - ], - }; - missingResolutions.set(cacheKey, missing); - return missing; - }, - }; + return new ApiReferenceProviderRegistry(new CSharpLanguageProvider(), [ + new TypeScriptLanguageProvider(), + ]).build({ + csharp: packages, + typescript: modules, + }); } diff --git a/src/frontend/src/utils/api-reference/csharp-provider.ts b/src/frontend/src/utils/api-reference/csharp-provider.ts new file mode 100644 index 000000000..eb9165723 --- /dev/null +++ b/src/frontend/src/utils/api-reference/csharp-provider.ts @@ -0,0 +1,440 @@ +import { memberNameSlug, resolveMemberAnchorMap } from '../api-member-anchors'; +import { sampleDescriptionText } from '../samples'; +import type { + ApiReferenceAttribute, + ApiReferenceDocNode, + ApiReferenceMember, + ApiReferencePackageDocument, + ApiReferenceTarget, + ApiReferenceType, +} from '../api-reference-core'; +import type { + ExportMapping, + PrimaryCandidateGroup, + PrimaryLanguageProvider, +} from './language-provider'; + +export interface CSharpCandidate { + fqn: string; + packageName: string; + type: ApiReferenceType; + members: ApiReferenceMember[]; +} + +export interface CSharpIndex { + candidates: Map>; + fqnsByMemberName: Map; +} + +const ASPIRE_EXPORT_ATTRIBUTE = /(?:^|\.)AspireExportAttribute$/; +const ASPIRE_EXPORT_IGNORE_ATTRIBUTE = /(?:^|\.)AspireExportIgnoreAttribute$/; +const CALLABLE_KINDS = new Set(['method', 'constructor', 'Method', 'InstanceMethod']); +const MEMBER_KIND_SLUGS: Record = { + constructor: 'constructors', + property: 'properties', + method: 'methods', + field: 'fields', + event: 'events', + indexer: 'indexers', +}; + +function genericArity(type: ApiReferenceType): number { + return type.genericParameters?.length ?? 0; +} + +function csharpTypePath(packageName: string, typeName: string, arity: number): string { + const typeSlug = `${typeName.toLowerCase()}${arity > 0 ? `-${arity}` : ''}`; + return `/reference/api/csharp/${packageName.toLowerCase()}/${typeSlug}/`; +} + +function genericArguments(value: string): string[] { + const start = value.indexOf('<'); + if (start < 0) return []; + + const argumentsList: string[] = []; + let depth = 0; + let current = ''; + + for (let index = start + 1; index < value.length; index++) { + const character = value[index]; + if (character === '<') { + depth++; + current += character; + } else if (character === '>') { + if (depth === 0) { + if (current.trim()) argumentsList.push(current.trim()); + break; + } + depth--; + current += character; + } else if (character === ',' && depth === 0) { + argumentsList.push(current.trim()); + current = ''; + } else { + current += character; + } + } + + return argumentsList; +} + +function lowerCamelCase(value: string): string { + return value ? value.charAt(0).toLowerCase() + value.slice(1) : value; +} + +function isCallable(kind: string | undefined): boolean { + return kind ? CALLABLE_KINDS.has(kind) : false; +} + +function readNamedString(attribute: ApiReferenceAttribute, name: string): string | undefined { + const value = attribute.arguments?.[name] ?? attribute.namedArguments?.[name]; + return typeof value === 'string' && value ? value : undefined; +} + +function readNamedBoolean(attribute: ApiReferenceAttribute, name: string): boolean { + const value = attribute.arguments?.[name] ?? attribute.namedArguments?.[name]; + return value === true || (typeof value === 'string' && value.toLowerCase() === 'true'); +} + +function extensionMappingIdentity(member: ApiReferenceMember): string { + if (!member.isExtension) return ''; + const receiver = + member.parameters?.find((parameter) => parameter.modifier === 'this')?.type ?? ''; + const constraints = (member.genericParameters ?? []) + .flatMap((parameter) => parameter.constraints ?? []) + .join(','); + return `${receiver}\0${constraints}`; +} + +function declaringFullName(candidate: CSharpCandidate): string { + return normalizeTypeName( + candidate.type.fullName ?? + `${candidate.type.namespace ? `${candidate.type.namespace}.` : ''}${candidate.type.name}` + ); +} + +function extensionReceiverTypes(candidate: CSharpCandidate, member: ApiReferenceMember): string[] { + if (!member.isExtension) return []; + const receiver = member.parameters?.find((parameter) => parameter.modifier === 'this'); + if (!receiver) return []; + + const genericParameters = [ + ...(candidate.type.genericParameters ?? []), + ...(member.genericParameters ?? []), + ]; + const receiverTypes = [receiver.type, ...genericArguments(receiver.type)]; + const resolved = new Set(); + + for (const receiverType of receiverTypes) { + const normalized = normalizeTypeName(receiverType); + const genericParameter = genericParameters.find((parameter) => parameter.name === normalized); + if (genericParameter?.constraints?.length) { + for (const constraint of genericParameter.constraints) { + resolved.add(normalizeTypeName(constraint)); + } + } else { + resolved.add(normalized); + } + } + + return [...resolved]; +} + +function declaringTypeNames(candidate: CSharpCandidate, member: ApiReferenceMember): string[] { + const declaringType = declaringFullName(candidate); + const receiverTypes = member.isExtension ? extensionReceiverTypes(candidate, member) : []; + return receiverTypes.length > 0 ? receiverTypes : [declaringType]; +} + +function getExportMappings(candidate: CSharpCandidate): ExportMapping[] { + const mappings = new Map(); + const typeExport = (candidate.type.attributes ?? []).find((attribute) => + ASPIRE_EXPORT_ATTRIBUTE.test(attribute.name) + ); + const exposeMethods = typeExport ? readNamedBoolean(typeExport, 'ExposeMethods') : false; + const exposeProperties = typeExport ? readNamedBoolean(typeExport, 'ExposeProperties') : false; + + for (const member of candidate.members) { + const memberDeclaringTypeNames = declaringTypeNames(candidate, member); + const attributes = (member.attributes ?? []).filter((attribute) => + ASPIRE_EXPORT_ATTRIBUTE.test(attribute.name) + ); + + if (attributes.length > 0) { + for (const attribute of attributes) { + const explicitId = attribute.constructorArguments?.[0]; + const capabilityId = + typeof explicitId === 'string' && explicitId + ? `${candidate.packageName}/${explicitId}` + : member.isStatic || member.isExtension + ? `${candidate.packageName}/${lowerCamelCase(member.name)}` + : undefined; + const methodName = readNamedString(attribute, 'MethodName') ?? lowerCamelCase(member.name); + const key = `${capabilityId ?? ''}\0${methodName}\0${extensionMappingIdentity(member)}`; + mappings.set(key, { + capabilityId, + methodName, + required: true, + allowUntargeted: false, + declaringTypeNames: memberDeclaringTypeNames, + isExtension: member.isExtension === true, + }); + } + continue; + } + + const ignoredAttributes = (member.attributes ?? []).filter((attribute) => + ASPIRE_EXPORT_IGNORE_ATTRIBUTE.test(attribute.name) + ); + if (ignoredAttributes.length > 0) { + const methodName = lowerCamelCase(member.name); + const allowUntargeted = ignoredAttributes.some((attribute) => + /\bdispatcher\b|\b(?:canonical|generic)\b[^.]*\bexport\b/i.test( + readNamedString(attribute, 'Reason') ?? '' + ) + ); + const key = `optional\0${methodName}\0${allowUntargeted}\0${extensionMappingIdentity(member)}`; + mappings.set(key, { + methodName, + required: false, + allowUntargeted, + declaringTypeNames: memberDeclaringTypeNames, + isExtension: member.isExtension === true, + }); + continue; + } + + const exposedByType = + (member.kind === 'method' && exposeMethods) || + (member.kind === 'property' && exposeProperties); + if (exposedByType) { + const methodName = lowerCamelCase(member.name); + const key = `type\0${methodName}\0${extensionMappingIdentity(member)}`; + mappings.set(key, { + methodName, + required: false, + allowUntargeted: false, + declaringTypeNames: memberDeclaringTypeNames, + isExtension: member.isExtension === true, + }); + } + } + + return [...mappings.values()]; +} + +function summaryText(summary: unknown): string | undefined { + if (typeof summary === 'string') { + return sampleDescriptionText(summary)?.replace(/\s+/g, ' ') || undefined; + } + if (!Array.isArray(summary)) return undefined; + + let text = ''; + for (const node of summary as ApiReferenceDocNode[]) { + const value = node.children + ? (summaryText(node.children) ?? '') + : (node.text ?? node.value ?? ''); + const label = + node.kind === 'cref' + ? value + .replace(/^[A-Z]:/, '') + .replace(/\(.*$/, '') + .replace(/``?\d+/g, '') + : value; + if (text && label && !/\s$/.test(text) && !/^[\s,.:;!?)}\]]/.test(label)) text += ' '; + text += label; + } + return text.replace(/\s+/g, ' ').trim() || undefined; +} + +function editDistance(left: string, right: string): number { + const previous = Array.from({ length: right.length + 1 }, (_, index) => index); + const current = new Array(right.length + 1); + + for (let leftIndex = 1; leftIndex <= left.length; leftIndex++) { + current[0] = leftIndex; + for (let rightIndex = 1; rightIndex <= right.length; rightIndex++) { + current[rightIndex] = Math.min( + current[rightIndex - 1] + 1, + previous[rightIndex] + 1, + previous[rightIndex - 1] + (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1) + ); + } + for (let index = 0; index < current.length; index++) { + previous[index] = current[index]; + } + } + + return previous[right.length]; +} + +function buildSuggestions( + name: string, + fqnsByMemberName: ReadonlyMap +): string[] { + const memberName = name.slice(name.lastIndexOf('.') + 1).toLowerCase(); + const exact = fqnsByMemberName.get(memberName); + if (exact?.length) return [...exact].slice(0, 8); + + return [...fqnsByMemberName.entries()] + .map(([candidateName, fqns]) => ({ + distance: editDistance(memberName, candidateName), + fqns, + })) + .sort((left, right) => left.distance - right.distance) + .filter(({ distance }) => distance <= Math.max(2, Math.floor(memberName.length / 3))) + .flatMap(({ fqns }) => fqns) + .slice(0, 8); +} + +export function normalizeTypeName(value: string): string { + const withoutAssembly = value.includes('/') ? value.slice(value.indexOf('/') + 1) : value; + return withoutAssembly + .trim() + .replace(/\?$/, '') + .replace(/^global::/, '') + .replace(/`\d+/g, '') + .replace(/<.*>$/, '') + .replace(/\[\[.*\]\]$/, ''); +} + +export class CSharpLanguageProvider implements PrimaryLanguageProvider< + ApiReferencePackageDocument, + CSharpIndex, + CSharpCandidate +> { + readonly id = 'csharp'; + readonly role = 'primary'; + readonly displayName = 'C#'; + readonly code = { + missing: 'missing-csharp', + ambiguous: 'ambiguous-csharp', + } as const; + + buildIndex(packages: readonly ApiReferencePackageDocument[]): CSharpIndex { + const candidates = new Map>(); + const fqnsByMemberName = new Map(); + + for (const pkg of packages) { + for (const type of pkg.types ?? []) { + const typeFullName = normalizeTypeName( + type.fullName ?? `${type.namespace ? `${type.namespace}.` : ''}${type.name}` + ); + + for (const member of type.members ?? []) { + const fqn = `${typeFullName}.${member.name}`; + const groupKey = `${pkg.package.name}\0${type.fullName ?? typeFullName}`; + const groups = candidates.get(fqn) ?? new Map(); + const group = groups.get(groupKey) ?? { + fqn, + packageName: pkg.package.name, + type, + members: [], + }; + group.members.push(member); + groups.set(groupKey, group); + candidates.set(fqn, groups); + + const memberName = member.name.toLowerCase(); + const memberFqns = fqnsByMemberName.get(memberName) ?? []; + if (!memberFqns.includes(fqn)) memberFqns.push(fqn); + fqnsByMemberName.set(memberName, memberFqns); + } + } + } + + return { candidates, fqnsByMemberName }; + } + + *enumerate(index: CSharpIndex): Iterable> { + for (const [fqn, groups] of index.candidates) { + const allMatches = [...groups.values()]; + const matchesByPackage = new Map(); + for (const match of allMatches) { + const packageMatches = matchesByPackage.get(match.packageName) ?? []; + packageMatches.push(match); + matchesByPackage.set(match.packageName, packageMatches); + } + yield { fqn, matchesByPackage, allMatches }; + } + } + + lookup(index: CSharpIndex, name: string, packageName?: string): CSharpCandidate[] { + return [...(index.candidates.get(name)?.values() ?? [])].filter( + (candidate) => !packageName || candidate.packageName === packageName + ); + } + + matchOverload( + candidates: readonly CSharpCandidate[], + parameterTypes: readonly string[] + ): CSharpCandidate[] { + return candidates.flatMap((candidate) => + candidate.members + .filter( + (member) => + isCallable(member.kind) && + (member.parameters ?? []).length === parameterTypes.length && + (member.parameters ?? []).every( + (parameter, index) => parameter.type === parameterTypes[index] + ) + ) + .map((member) => ({ ...candidate, members: [member] })) + ); + } + + createTarget( + candidate: CSharpCandidate, + options: { exactOverload: boolean } + ): ApiReferenceTarget { + const member = candidate.members[0]; + const kind = member.kind ?? 'method'; + const anchor = options.exactOverload + ? resolveMemberAnchorMap(candidate.type.members ?? []).get(member)!.exact + : memberNameSlug(member); + const parameters = (member.parameters ?? []) + .filter((parameter) => !member.isExtension || parameter.modifier !== 'this') + .map( + (parameter) => + `${parameter.modifier ? `${parameter.modifier} ` : ''}${parameter.type.replace(/\b(?:[A-Za-z_]\w*\.)+/g, '')}${parameter.name ? ` ${parameter.name}` : ''}` + ); + return { + label: options.exactOverload ? `${member.name}(${parameters.join(', ')})` : member.name, + description: summaryText(member.docs?.summary), + path: `${csharpTypePath( + candidate.packageName, + candidate.type.name, + genericArity(candidate.type) + )}${MEMBER_KIND_SLUGS[kind] ?? `${kind}s`}/#${anchor}`, + }; + } + + describeCandidate(candidate: CSharpCandidate): string { + const signatures = candidate.members + .map((member) => member.signature) + .filter((signature): signature is string => Boolean(signature)); + return signatures.length > 0 + ? `${candidate.packageName}: ${signatures.join(' | ')}` + : `${candidate.packageName}: ${candidate.fqn}`; + } + + suggestionsFor(index: CSharpIndex, name: string): string[] { + return buildSuggestions(name, index.fqnsByMemberName); + } + + exportMappings(candidate: CSharpCandidate): ExportMapping[] { + return getExportMappings(candidate); + } + + canonicalMemberName(candidate: CSharpCandidate): string { + return lowerCamelCase(candidate.members[0].name).toLowerCase(); + } + + packageOf(candidate: CSharpCandidate): string { + return candidate.packageName; + } + + fqnOf(candidate: CSharpCandidate): string { + return candidate.fqn; + } +} diff --git a/src/frontend/src/utils/api-reference/language-provider.ts b/src/frontend/src/utils/api-reference/language-provider.ts new file mode 100644 index 000000000..35ff1ba23 --- /dev/null +++ b/src/frontend/src/utils/api-reference/language-provider.ts @@ -0,0 +1,81 @@ +import type { + ApiReferenceDiagnostic, + ApiReferenceDiagnosticCode, + ApiReferenceTarget, +} from '../api-reference-core'; + +export interface ApiLanguageProvider { + readonly id: string; + buildIndex(documents: readonly TDocument[]): TIndex; +} + +export interface PrimaryLanguageProvider extends ApiLanguageProvider< + TDocument, + TIndex +> { + readonly role: 'primary'; + readonly displayName: string; + readonly code: { + readonly missing: ApiReferenceDiagnosticCode; + readonly ambiguous: ApiReferenceDiagnosticCode; + }; + enumerate(index: TIndex): Iterable>; + lookup(index: TIndex, name: string, packageName?: string): TCandidate[]; + matchOverload(candidates: readonly TCandidate[], parameterTypes: readonly string[]): TCandidate[]; + createTarget(candidate: TCandidate, options: { exactOverload: boolean }): ApiReferenceTarget; + describeCandidate(candidate: TCandidate): string; + suggestionsFor(index: TIndex, name: string): string[]; + exportMappings(candidate: TCandidate): ExportMapping[]; + packageOf(candidate: TCandidate): string; + fqnOf(candidate: TCandidate): string; + /** + * Canonical, language-neutral member name for cross-language matching + * (for example, `addwidget` for a C# `AddWidget` member). Used by target + * providers to prefer the natural mapping over `[AspireExport(MethodName)]` + * overrides so a method-group reference does not silently pick an + * arbitrary export when a member declares several. + */ + canonicalMemberName(candidate: TCandidate): string; +} + +export interface TargetLanguageProvider extends ApiLanguageProvider< + TDocument, + TIndex +> { + readonly role: 'target'; + resolveTarget( + context: PrimaryResolutionContext, + primaryTarget: ApiReferenceTarget, + index: TIndex, + options: { exactOverload: boolean } + ): { target: ApiReferenceTarget; diagnostics: ApiReferenceDiagnostic[] }; +} + +export interface ExportMapping { + capabilityId?: string; + methodName: string; + required: boolean; + allowUntargeted: boolean; + declaringTypeNames: readonly string[]; + isExtension: boolean; +} + +export interface PrimaryResolutionContext { + fqn: string; + packageName: string; + /** + * Canonical, language-neutral member name from the primary provider + * (see {@link PrimaryLanguageProvider.canonicalMemberName}). Target + * providers should prefer routes matching this name over routes picked + * up via `[AspireExport(MethodName)]` overrides in {@link mappings}. + */ + canonicalName: string; + mappings: readonly ExportMapping[]; + describe(): string; +} + +export interface PrimaryCandidateGroup { + fqn: string; + matchesByPackage: ReadonlyMap; + allMatches: readonly TCandidate[]; +} diff --git a/src/frontend/src/utils/api-reference/registry.ts b/src/frontend/src/utils/api-reference/registry.ts new file mode 100644 index 000000000..162b18e62 --- /dev/null +++ b/src/frontend/src/utils/api-reference/registry.ts @@ -0,0 +1,217 @@ +import { + API_REFERENCE_FQN_PATTERN, + type ApiReferenceDiagnostic, + type ApiReferenceIndex, + type ApiReferenceResolution, + type ApiReferenceTarget, +} from '../api-reference-core'; +import type { + PrimaryLanguageProvider, + PrimaryResolutionContext, + TargetLanguageProvider, +} from './language-provider'; + +function fallbackTarget(name: string): ApiReferenceTarget { + const label = name.slice(name.lastIndexOf('.') + 1) || name || 'Unknown API'; + return { label }; +} + +export class ApiReferenceProviderRegistry { + constructor( + private readonly primary: PrimaryLanguageProvider, + private readonly targets: readonly TargetLanguageProvider[] + ) {} + + build(documentsByProvider: Record): ApiReferenceIndex { + const primaryIndex = this.primary.buildIndex( + (documentsByProvider[this.primary.id] ?? []) as readonly TPrimaryDocument[] + ); + const targetIndexes = new Map(); + for (const target of this.targets) { + targetIndexes.set(target.id, target.buildIndex(documentsByProvider[target.id] ?? [])); + } + + const resolutions = new Map(); + const packageResolutions = new Map(); + + const withAliases = ( + name: string, + status: ApiReferenceResolution['status'], + targets: Record, + diagnostics: ApiReferenceDiagnostic[] + ): ApiReferenceResolution => { + const csharp = targets.csharp ?? fallbackTarget(name); + const typescript = targets.typescript ?? csharp; + return { + name, + status, + primaryLanguage: this.primary.id, + targets, + csharp, + typescript, + diagnostics, + }; + }; + + const fallbackResolution = ( + name: string, + status: ApiReferenceResolution['status'], + diagnostics: ApiReferenceDiagnostic[] + ): ApiReferenceResolution => { + const fallback = fallbackTarget(name); + const targets: Record = { + [this.primary.id]: fallback, + }; + for (const target of this.targets) { + targets[target.id] = fallback; + } + return withAliases(name, status, targets, diagnostics); + }; + + const resolvedResolution = ( + candidate: TCandidate, + exactOverload: boolean + ): ApiReferenceResolution => { + const primaryTarget = this.primary.createTarget(candidate, { exactOverload }); + const context: PrimaryResolutionContext = { + fqn: this.primary.fqnOf(candidate), + packageName: this.primary.packageOf(candidate), + canonicalName: this.primary.canonicalMemberName(candidate), + mappings: this.primary.exportMappings(candidate), + describe: () => this.primary.describeCandidate(candidate), + }; + const targets: Record = { + [this.primary.id]: primaryTarget, + }; + const diagnostics: ApiReferenceDiagnostic[] = []; + for (const targetProvider of this.targets) { + const resolved = targetProvider.resolveTarget( + context, + primaryTarget, + targetIndexes.get(targetProvider.id), + { exactOverload } + ); + targets[targetProvider.id] = resolved.target; + diagnostics.push(...resolved.diagnostics); + } + return withAliases(context.fqn, 'resolved', targets, diagnostics); + }; + + for (const group of this.primary.enumerate(primaryIndex)) { + const matches = group.allMatches; + for (const [packageName, packageMatches] of group.matchesByPackage) { + if (packageMatches.length > 1) { + packageResolutions.set( + `${packageName}\0${group.fqn}`, + fallbackResolution(group.fqn, 'ambiguous', [ + { + code: this.primary.code.ambiguous, + severity: 'error', + message: `ApiReference: "${group.fqn}" resolves to multiple generated ${this.primary.displayName} API members in package "${packageName}".`, + candidates: packageMatches + .map((candidate) => this.primary.describeCandidate(candidate)) + .sort(), + }, + ]) + ); + continue; + } + + const match = packageMatches[0]; + packageResolutions.set(`${packageName}\0${group.fqn}`, resolvedResolution(match, false)); + } + + if (matches.length > 1) { + resolutions.set( + group.fqn, + fallbackResolution(group.fqn, 'ambiguous', [ + { + code: this.primary.code.ambiguous, + severity: 'error', + message: `ApiReference: "${group.fqn}" resolves to multiple generated ${this.primary.displayName} API members.`, + candidates: matches + .map((candidate) => this.primary.describeCandidate(candidate)) + .sort(), + }, + ]) + ); + continue; + } + + resolutions.set( + group.fqn, + packageResolutions.get(`${this.primary.packageOf(matches[0])}\0${group.fqn}`)! + ); + } + + const missingResolutions = new Map(); + const overloadResolutions = new Map(); + + return { + size: packageResolutions.size, + resolve: ( + name: string, + packageName?: string, + parameterTypes?: readonly string[] + ): ApiReferenceResolution => { + if (parameterTypes !== undefined) { + const cacheKey = JSON.stringify([name, packageName, parameterTypes]); + const cached = overloadResolutions.get(cacheKey); + if (cached) return cached; + + const groups = this.primary.lookup(primaryIndex, name, packageName); + const matches = this.primary.matchOverload(groups, parameterTypes); + let resolution: ApiReferenceResolution; + if (matches.length === 1) { + resolution = resolvedResolution(matches[0], true); + } else { + resolution = fallbackResolution(name, matches.length > 1 ? 'ambiguous' : 'missing', [ + { + code: matches.length > 1 ? 'ambiguous-overload' : 'missing-overload', + severity: 'error', + message: `ApiReference: "${name}" with parameter types ${JSON.stringify(parameterTypes)} ${matches.length > 1 ? 'matches multiple overloads' : 'does not match a generated overload'}. Use the complete declared C# parameter types, including the extension receiver, and qualify the package if needed.`, + candidates: (matches.length > 1 ? matches : groups) + .map((candidate) => this.primary.describeCandidate(candidate)) + .sort(), + }, + ]); + } + overloadResolutions.set(cacheKey, resolution); + return resolution; + } + + const resolved = packageName + ? packageResolutions.get(`${packageName}\0${name}`) + : resolutions.get(name); + if (resolved) return resolved; + + const cacheKey = `${packageName ?? ''}\0${name}`; + const cached = missingResolutions.get(cacheKey); + if (cached) return cached; + + const validFqn = API_REFERENCE_FQN_PATTERN.test(name); + const packageCandidates = this.primary.lookup(primaryIndex, name); + const candidatesForDiagnostic = packageCandidates.length + ? packageCandidates.map((candidate) => this.primary.describeCandidate(candidate)).sort() + : validFqn + ? this.primary.suggestionsFor(primaryIndex, name) + : []; + const missing = fallbackResolution(name, 'missing', [ + { + code: validFqn ? this.primary.code.missing : 'invalid-fqn', + severity: 'error', + message: + validFqn && packageName + ? `ApiReference: could not resolve "${name}" in package "${packageName}".` + : validFqn + ? `ApiReference: could not resolve "${name}" to a generated ${this.primary.displayName} API member.` + : `ApiReference: "${name}" is not a canonical fully qualified API member name.`, + candidates: candidatesForDiagnostic, + }, + ]); + missingResolutions.set(cacheKey, missing); + return missing; + }, + }; + } +} diff --git a/src/frontend/src/utils/api-reference/typescript-provider.ts b/src/frontend/src/utils/api-reference/typescript-provider.ts new file mode 100644 index 000000000..73f3970b6 --- /dev/null +++ b/src/frontend/src/utils/api-reference/typescript-provider.ts @@ -0,0 +1,357 @@ +import { sampleDescriptionText } from '../samples'; +import { + getTsItemSlug, + getTsMemberAnchor, + getTsMethodSlug, + getTsTopLevelRouteItems, +} from '../ts-api-routes'; +import type { + ApiReferenceDiagnostic, + ApiReferenceTarget, + ApiReferenceTsCallable, + ApiReferenceTsDocument, +} from '../api-reference-core'; +import type { + ExportMapping, + PrimaryResolutionContext, + TargetLanguageProvider, +} from './language-provider'; + +interface TsRouteCandidate { + moduleName: string; + name: string; + kind?: string; + capabilityId?: string; + qualifiedName?: string; + targetTypeId?: string; + expandedTargetTypes: string[]; + parentTypeFullName?: string; + path: string; + priority: number; + parameters?: ApiReferenceTsCallable['parameters']; + description?: string; +} + +interface TsRouteIndex { + byCapabilityId: Map; + byModuleAndName: Map; + byName: Map; +} + +const CALLABLE_KINDS = new Set(['method', 'constructor', 'Method', 'InstanceMethod']); + +function addToIndex(index: Map, key: string | undefined, value: T): void { + if (!key) return; + const values = index.get(key) ?? []; + values.push(value); + index.set(key, values); +} + +function tsModuleSlug(name: string): string { + return name.toLowerCase(); +} + +function normalizeTypeName(value: string): string { + const withoutAssembly = value.includes('/') ? value.slice(value.indexOf('/') + 1) : value; + return withoutAssembly + .trim() + .replace(/\?$/, '') + .replace(/^global::/, '') + .replace(/`\d+/g, '') + .replace(/<.*>$/, '') + .replace(/\[\[.*\]\]$/, ''); +} + +function simpleTypeName(value: string): string { + const normalized = normalizeTypeName(value); + return normalized.slice(normalized.lastIndexOf('.') + 1); +} + +function isCallable(kind: string | undefined): boolean { + return kind ? CALLABLE_KINDS.has(kind) : false; +} + +function buildTsRouteIndex(modules: readonly ApiReferenceTsDocument[]): TsRouteIndex { + const byCapabilityId = new Map(); + const byModuleAndName = new Map(); + const byName = new Map(); + + const register = (candidate: TsRouteCandidate) => { + addToIndex(byCapabilityId, candidate.capabilityId, candidate); + addToIndex(byName, candidate.name.toLowerCase(), candidate); + addToIndex( + byModuleAndName, + `${candidate.moduleName}\0${candidate.name.toLowerCase()}`, + candidate + ); + }; + + for (const module of modules) { + const moduleName = module.package.name; + const modulePath = `/reference/api/typescript/${tsModuleSlug(moduleName)}`; + const topLevelItems = getTsTopLevelRouteItems(module); + + const standaloneFunctions = (module.functions ?? []).filter( + (fn) => !fn.qualifiedName || !fn.qualifiedName.includes('.') + ); + for (const fn of standaloneFunctions) { + register({ + moduleName, + name: fn.name, + kind: fn.kind, + capabilityId: fn.capabilityId, + qualifiedName: fn.qualifiedName, + targetTypeId: fn.targetTypeId, + expandedTargetTypes: fn.expandedTargetTypes ?? [], + path: `${modulePath}/${getTsItemSlug(fn, topLevelItems)}/`, + priority: 0, + parameters: fn.parameters, + description: fn.description, + }); + } + + for (const handle of module.handleTypes ?? []) { + const itemSlug = getTsItemSlug(handle, topLevelItems); + const methods = (handle.capabilities ?? []).filter( + (capability) => capability.kind === 'Method' || capability.kind === 'InstanceMethod' + ); + + for (const capability of handle.capabilities ?? []) { + let path: string | undefined; + let priority = 1; + + if (capability.kind === 'Method' || capability.kind === 'InstanceMethod') { + path = `${modulePath}/${itemSlug}/${getTsMethodSlug(capability, methods, handle.name)}/`; + } else if (capability.kind === 'PropertyGetter' || capability.kind === 'PropertySetter') { + path = `${modulePath}/${itemSlug}/#${getTsMemberAnchor(capability.name)}`; + priority = capability.kind === 'PropertyGetter' ? 1 : 2; + } + + if (!path) continue; + + register({ + moduleName, + name: capability.name, + kind: capability.kind, + capabilityId: capability.capabilityId, + qualifiedName: capability.qualifiedName, + targetTypeId: capability.targetTypeId, + expandedTargetTypes: capability.expandedTargetTypes ?? [], + parentTypeFullName: handle.fullName, + path, + priority, + parameters: capability.parameters, + description: capability.description, + }); + } + } + } + + return { byCapabilityId, byModuleAndName, byName }; +} + +function candidateMatchesMapping(route: TsRouteCandidate, mapping: ExportMapping): boolean { + const normalizedExpectedTypes = mapping.declaringTypeNames.map(normalizeTypeName); + const expectedSimpleNames = normalizedExpectedTypes.map(simpleTypeName); + const targetTypes = [ + route.parentTypeFullName, + route.targetTypeId, + ...route.expandedTargetTypes, + ].filter((value): value is string => Boolean(value)); + + if ( + targetTypes.some((targetType) => + normalizedExpectedTypes.includes(normalizeTypeName(targetType)) + ) + ) { + return true; + } + + const qualifiedName = route.qualifiedName; + const lastDot = qualifiedName?.lastIndexOf('.') ?? -1; + return ( + lastDot > 0 && expectedSimpleNames.includes(simpleTypeName(qualifiedName!.slice(0, lastDot))) + ); +} + +function deduplicateTsCandidates(candidates: readonly TsRouteCandidate[]): TsRouteCandidate[] { + const unique = new Map(); + for (const candidate of candidates) { + const key = `${candidate.path}\0${candidate.name}\0${candidate.kind ?? ''}`; + const current = unique.get(key); + if (!current || candidate.priority < current.priority) { + unique.set(key, candidate); + } + } + return [...unique.values()]; +} + +function preferredTsCandidates(candidates: readonly TsRouteCandidate[]): TsRouteCandidate[] { + const unique = deduplicateTsCandidates(candidates); + if (unique.length <= 1) return unique; + const priority = Math.min(...unique.map((candidate) => candidate.priority)); + return unique.filter((candidate) => candidate.priority === priority); +} + +function describeTsCandidate(candidate: TsRouteCandidate): string { + return `${candidate.moduleName}:${candidate.qualifiedName ?? candidate.name} (${candidate.path})`; +} + +function findTsCandidates( + mapping: ExportMapping, + context: PrimaryResolutionContext, + tsIndex: TsRouteIndex +): { matches: TsRouteCandidate[]; suggestions: TsRouteCandidate[] } { + if (mapping.capabilityId) { + const exact = tsIndex.byCapabilityId.get(mapping.capabilityId) ?? []; + if (exact.length > 0) { + return { matches: preferredTsCandidates(exact), suggestions: exact }; + } + } + + const named = + tsIndex.byModuleAndName.get(`${context.packageName}\0${mapping.methodName.toLowerCase()}`) ?? + []; + const targeted = named.filter((route) => candidateMatchesMapping(route, mapping)); + + if (targeted.length > 0) { + return { matches: preferredTsCandidates(targeted), suggestions: named }; + } + + if (mapping.allowUntargeted) { + const globalNamed = tsIndex.byName.get(mapping.methodName.toLowerCase()) ?? []; + const globalTargeted = globalNamed.filter((route) => candidateMatchesMapping(route, mapping)); + if (globalTargeted.length > 0) { + return { + matches: preferredTsCandidates(globalTargeted), + suggestions: globalNamed, + }; + } + return { matches: preferredTsCandidates(named), suggestions: named }; + } + + return { matches: [], suggestions: named }; +} + +export class TypeScriptLanguageProvider implements TargetLanguageProvider< + ApiReferenceTsDocument, + TsRouteIndex +> { + readonly id = 'typescript'; + readonly role = 'target'; + + buildIndex(documents: readonly ApiReferenceTsDocument[]): TsRouteIndex { + return buildTsRouteIndex(documents); + } + + resolveTarget( + context: PrimaryResolutionContext, + primaryTarget: ApiReferenceTarget, + index: TsRouteIndex, + options: { exactOverload: boolean } + ): { target: ApiReferenceTarget; diagnostics: ApiReferenceDiagnostic[] } { + if (context.mappings.length === 0) { + return { + target: { label: primaryTarget.label }, + diagnostics: [ + { + code: 'missing-typescript', + severity: 'warning', + message: `ApiReference: "${context.fqn}" has no TypeScript export; the C# API name is shown without a TypeScript link.`, + candidates: [], + }, + ], + }; + } + + const matches: TsRouteCandidate[] = []; + const suggestions: TsRouteCandidate[] = []; + let unresolved = false; + + for (const mapping of context.mappings) { + const result = findTsCandidates(mapping, context, index); + suggestions.push(...result.suggestions); + if (result.matches.length === 0) { + unresolved ||= mapping.required; + continue; + } + matches.push(...result.matches); + } + + const canonicalName = context.canonicalName; + const canonicalMatches = canonicalName + ? matches.filter((match) => match.name.toLowerCase() === canonicalName) + : []; + const preferred = preferredTsCandidates( + canonicalMatches.length > 0 ? canonicalMatches : matches + ); + const candidateDescriptions = deduplicateTsCandidates( + suggestions.length > 0 ? suggestions : matches + ) + .map(describeTsCandidate) + .sort(); + + if (unresolved) { + return { + target: { label: primaryTarget.label }, + diagnostics: [ + { + code: 'unresolved-typescript-export', + severity: 'error', + message: `ApiReference: "${context.fqn}" declares a TypeScript export, but no generated TypeScript API matched it.`, + candidates: candidateDescriptions, + }, + ], + }; + } + + if (preferred.length === 0) { + return { + target: { label: primaryTarget.label }, + diagnostics: [ + { + code: 'missing-typescript', + severity: 'warning', + message: `ApiReference: "${context.fqn}" has no TypeScript export; the C# API name is shown without a TypeScript link.`, + candidates: candidateDescriptions, + }, + ], + }; + } + + if (preferred.length > 1) { + return { + target: { label: primaryTarget.label }, + diagnostics: [ + { + code: 'ambiguous-typescript', + severity: 'error', + message: `ApiReference: "${context.fqn}" maps to multiple generated TypeScript APIs.`, + candidates: preferred.map(describeTsCandidate).sort(), + }, + ], + }; + } + + const match = preferred[0]; + return { + target: { + description: + sampleDescriptionText(match.description ?? null)?.replace(/\s+/g, ' ') || undefined, + label: + options.exactOverload && isCallable(match.kind) + ? `${match.name}(${(match.parameters ?? []) + .map((parameter) => { + const type = parameter.callbackSignature ?? parameter.type; + return parameter.name + ? `${parameter.name}${parameter.isOptional ? '?' : ''}${type ? `: ${type}` : ''}` + : type; + }) + .join(', ')})` + : match.name, + path: match.path, + }, + diagnostics: [], + }; + } +} diff --git a/src/frontend/tests/unit/api-reference.vitest.test.ts b/src/frontend/tests/unit/api-reference.vitest.test.ts index 2138de733..c1aa34759 100644 --- a/src/frontend/tests/unit/api-reference.vitest.test.ts +++ b/src/frontend/tests/unit/api-reference.vitest.test.ts @@ -624,6 +624,264 @@ describe('API reference index', () => { candidates: ['Aspire.Hosting.WidgetBuilderExtensions.AddWidget'], }); }); + + it('reports ambiguity when a member exports multiple TypeScript method names', () => { + // A member with two [AspireExport] attributes declaring different MethodNames + // must not silently pick one for a method-group reference: the canonical + // name from the primary member (`addWidget`) is absent from both exports, + // so both TS candidates remain and the resolver flags ambiguity. + const index = buildApiReferenceIndex( + [ + packageDocument('Aspire.Hosting.Widget', [ + { + name: 'WidgetBuilderExtensions', + fullName: 'Aspire.Hosting.WidgetBuilderExtensions', + members: [ + { + name: 'AddWidget', + kind: 'method', + isStatic: true, + isExtension: true, + attributes: [ + { + name: EXPORT_ATTRIBUTE, + constructorArguments: ['primary'], + arguments: { MethodName: 'addWidget0' }, + }, + { + name: EXPORT_ATTRIBUTE, + constructorArguments: ['secondary'], + arguments: { MethodName: 'addWidget1' }, + }, + ], + }, + ], + }, + ]), + ], + [ + tsDocument('Aspire.Hosting.Widget', [ + { + name: 'addWidget0', + kind: 'Method', + capabilityId: 'Aspire.Hosting.Widget/primary', + qualifiedName: 'addWidget0', + }, + { + name: 'addWidget1', + kind: 'Method', + capabilityId: 'Aspire.Hosting.Widget/secondary', + qualifiedName: 'addWidget1', + }, + ]) + ] + ); + + expect( + index.resolve('Aspire.Hosting.WidgetBuilderExtensions.AddWidget').diagnostics + ).toMatchObject([{ code: 'ambiguous-typescript', severity: 'error' }]); + }); + + it('populates the language-neutral targets record for every registered provider', () => { + const index = buildApiReferenceIndex([widgetPackage], [widgetModule]); + const resolution = index.resolve('Aspire.Hosting.WidgetBuilderExtensions.AddWidget'); + + expect(resolution.primaryLanguage).toBe('csharp'); + expect(resolution.targets).toBeDefined(); + // The registry MUST populate targets — the astro renderer iterates that + // map and only falls back to the legacy csharp/typescript aliases when + // targets is missing, which would silently hide regressions. + expect(resolution.targets?.csharp).toBe(resolution.csharp); + expect(resolution.targets?.typescript).toBe(resolution.typescript); + expect(Object.keys(resolution.targets ?? {}).sort()).toEqual(['csharp', 'typescript']); + }); + + it('routes target-owned diagnostics through the registered target providers', () => { + // The missing-typescript diagnostic is emitted by the TypeScript target + // provider, not the primary C# provider. Verifying it flows through the + // registry ensures newly registered target providers can attach their + // own diagnostics in the same way. + const index = buildApiReferenceIndex( + [ + packageDocument('Aspire.Hosting', [ + { + name: 'ResourceBuilderExtensions', + fullName: 'Aspire.Hosting.ResourceBuilderExtensions', + members: [{ name: 'WithAnnotation', kind: 'method' }], + }, + ]), + ], + [tsDocument('Aspire.Hosting', [])] + ); + const resolution = index.resolve( + 'Aspire.Hosting.ResourceBuilderExtensions.WithAnnotation' + ); + + expect(resolution.status).toBe('resolved'); + expect(resolution.targets?.typescript).toEqual({ label: 'WithAnnotation' }); + expect(resolution.diagnostics).toMatchObject([ + { code: 'missing-typescript', severity: 'warning' }, + ]); + }); +}); + +describe('API reference provider registry', () => { + it('drives an additional target-language provider without core changes', async () => { + // Simulates a future AppHost language: register a third provider with a + // non-legacy id and verify the registry: (a) builds its index, (b) + // supplies it with the ExportMapping cross-language contract, (c) + // surfaces its target under resolution.targets keyed by its id, and + // (d) forwards its diagnostics unchanged. + const { ApiReferenceProviderRegistry } = await import( + '@utils/api-reference/registry' + ); + const { CSharpLanguageProvider } = await import( + '@utils/api-reference/csharp-provider' + ); + + interface PythonModuleDocument { + moduleName: string; + exports: { capabilityId?: string; methodName: string; snakeCase: string }[]; + } + + interface PythonIndex { + byCapabilityId: Map; + byMethodName: Map; + } + + const pythonModule: PythonModuleDocument = { + moduleName: 'aspire.hosting.widget', + exports: [ + { + capabilityId: 'Aspire.Hosting.Widget/addWidget', + methodName: 'addWidget', + snakeCase: 'add_widget', + }, + ], + }; + + let buildIndexCalls = 0; + let resolveTargetCalls = 0; + const pythonProvider = { + id: 'python', + role: 'target' as const, + buildIndex(documents: readonly PythonModuleDocument[]): PythonIndex { + buildIndexCalls++; + const byCapabilityId = new Map< + string, + PythonModuleDocument['exports'][number] & { module: string } + >(); + const byMethodName = new Map< + string, + PythonModuleDocument['exports'][number] & { module: string } + >(); + for (const document of documents) { + for (const entry of document.exports) { + const enriched = { ...entry, module: document.moduleName }; + if (entry.capabilityId) byCapabilityId.set(entry.capabilityId, enriched); + byMethodName.set(entry.methodName.toLowerCase(), enriched); + } + } + return { byCapabilityId, byMethodName }; + }, + resolveTarget(context, primaryTarget, index: PythonIndex) { + resolveTargetCalls++; + for (const mapping of context.mappings) { + const match = + (mapping.capabilityId && index.byCapabilityId.get(mapping.capabilityId)) || + index.byMethodName.get(mapping.methodName.toLowerCase()); + if (match) { + return { + target: { + label: match.snakeCase, + path: `/reference/api/python/${match.module}/${match.snakeCase}/`, + }, + diagnostics: [], + }; + } + } + return { + target: { label: primaryTarget.label }, + diagnostics: [ + { + code: 'missing-typescript', + severity: 'warning', + message: `ApiReference: "${context.fqn}" has no Python export.`, + candidates: [], + } as const, + ], + }; + }, + }; + + const registry = new ApiReferenceProviderRegistry(new CSharpLanguageProvider(), [ + pythonProvider, + ]); + const index = registry.build({ + csharp: [widgetPackage], + python: [pythonModule], + }); + const resolution = index.resolve('Aspire.Hosting.WidgetBuilderExtensions.AddWidget'); + + expect(buildIndexCalls).toBe(1); + expect(resolveTargetCalls).toBeGreaterThan(0); + expect(resolution.status).toBe('resolved'); + expect(resolution.primaryLanguage).toBe('csharp'); + expect(resolution.targets).toBeDefined(); + expect(Object.keys(resolution.targets ?? {}).sort()).toEqual(['csharp', 'python']); + expect(resolution.targets?.python).toEqual({ + label: 'add_widget', + path: '/reference/api/python/aspire.hosting.widget/add_widget/', + }); + expect(resolution.diagnostics).toEqual([]); + }); + + it('emits a target-owned missing diagnostic when the registered provider cannot resolve', async () => { + const { ApiReferenceProviderRegistry } = await import( + '@utils/api-reference/registry' + ); + const { CSharpLanguageProvider } = await import( + '@utils/api-reference/csharp-provider' + ); + + const pythonProvider = { + id: 'python', + role: 'target' as const, + buildIndex() { + return {}; + }, + resolveTarget(context: { fqn: string }, primaryTarget: { label: string }) { + return { + target: { label: primaryTarget.label }, + diagnostics: [ + { + code: 'missing-typescript', + severity: 'warning', + message: `ApiReference: "${context.fqn}" has no Python export.`, + candidates: [], + } as const, + ], + }; + }, + }; + + const registry = new ApiReferenceProviderRegistry(new CSharpLanguageProvider(), [ + pythonProvider, + ]); + const index = registry.build({ + csharp: [widgetPackage], + python: [], + }); + const resolution = index.resolve('Aspire.Hosting.WidgetBuilderExtensions.AddWidget'); + + expect(resolution.targets?.python).toEqual({ label: 'AddWidget' }); + expect(resolution.diagnostics).toMatchObject([ + { + severity: 'warning', + message: expect.stringContaining('Python export'), + }, + ]); + }); }); describe('API reference overloads', () => {