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}
-
-
- ) : (
-
- {resolution.csharp.label}
-
- )
- }
- {
- typescriptHref ? (
-
-
-
- {resolution.typescript.label}
-
-
- ) : (
-
- {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}
+
+
+ ) : (
+
+ {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', () => {