// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Reflection;
using System.Text;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Aspire.Shared.CodeGeneration;
using Aspire.Shared.Json;
using Aspire.TypeSystem;
namespace Aspire.Hosting.CodeGeneration.TypeScript;
/// <summary>
/// Resolves an <see cref="AtsContext"/> into the TypeScript-specific decisions that define the
/// public SDK surface: type mapping, options flattening, callback shaping, promise wrapping, and
/// fluent return selection.
/// </summary>
/// <remarks>
/// <para>
/// This type is the single owner of those decisions. <see cref="AtsTypeScriptCodeGenerator"/>
/// consumes it to emit runtime source, and <see cref="TypeScriptApiExportWriter"/> consumes the
/// same resolved model to emit the canonical API export. Documentation that reconstructs
/// signatures from raw ATS instead drifts from the SDK that actually ships, which is the failure
/// mode tracked by microsoft/aspire#17608.
/// </para>
/// <para>
/// Resolution happens in the constructor so the mapping members can never be called before the
/// wrapper class and options interface tables they depend on exist.
/// </para>
/// </remarks>
internal sealed partial class TypeScriptApiProjector
{
/// <summary>The schema version of the canonical export document this projector produces.</summary>
public const int ExportSchemaVersion = 1;
/// <summary>
/// Base library symbols that generated declarations reference but that the SDK ships by hand in
/// <c>base.mts</c>/<c>transport.mts</c> rather than generating per package. Each package export
/// includes these symbols under a well-known package-local declaration ID so its declarations
/// type-check without site-authored shims.
/// </summary>
private const string RuntimeDeclarationId = "aspire:runtime:base";
private static readonly TypeScriptApiGeneratorIdentity s_generatorIdentity = CreateGeneratorIdentity();
/// <summary>The symbol names <see cref="RuntimeDeclarationContent"/> already declares.</summary>
private static readonly HashSet<string> s_runtimeDeclaredNames = new(StringComparer.Ordinal)
{
"Awaitable", "MarshalledHandle", "Handle", "HandleReference", "AbortSignal", "CancellationToken",
"ReferenceExpression", "AspireList", "AspireDict", "ResourceBuilderBase", "InputType",
"InteractionInput", "InteractionInputCollection", "InteractionInputCollectionPromise",
// Every exported entry point is a free function that takes the client explicitly
// (see EntryPointClientParameterType), so a package contributing an entry point names this
// symbol in a signature. Without it here the fragment would be the only self-contained
// declaration set that does not compile on its own.
"AspireClientRpc"
};
private const string RuntimeDeclarationContent = """
export type Awaitable<T> = T | PromiseLike<T>;
export interface MarshalledHandle { $handle: string; $type: string; }
export interface Handle<T extends string = string> { readonly $handle: string; readonly $type: T; toJSON(): MarshalledHandle; }
export interface HandleReference { toJSON(): MarshalledHandle; }
export interface AbortSignal { readonly aborted: boolean; }
export interface CancellationToken { readonly aborted: boolean; }
export enum InputType { Text = 'Text', SecretText = 'SecretText', Choice = 'Choice', Boolean = 'Boolean', Number = 'Number' }
export interface ReferenceExpression { readonly value: Promise<string>; }
export interface AspireList<T> extends HandleReference { get(index: number): Promise<T>; }
export interface AspireDict<TKey, TValue> extends HandleReference { get(key: TKey): Promise<TValue>; }
export interface ResourceBuilderBase extends HandleReference {}
export interface InteractionInput { readonly name: string; }
export interface InteractionInputCollection extends HandleReference {}
export interface InteractionInputCollectionPromise extends PromiseLike<InteractionInputCollection> {}
export interface AspireClientRpc { readonly connected: boolean; invokeCapability<TResult = unknown>(capabilityId: string, args?: Record<string, unknown>): Promise<TResult>; }
""";
private readonly TypeScriptResolvedModel _resolved;
/// <summary>The client parameter every entry-point function takes first.</summary>
private const string EntryPointClientParameterName = "client";
/// <summary>The declared type of <see cref="EntryPointClientParameterName"/>.</summary>
private const string EntryPointClientParameterType = "AspireClientRpc";
public TypeScriptApiProjector(AtsContext context)
{
ArgumentNullException.ThrowIfNull(context);
_resolved = Resolve(context);
}
/// <summary>Gets the resolved projection of the context this projector was built from.</summary>
internal TypeScriptResolvedModel Resolved => _resolved;
/// <summary>Gets the mapping of ATS type ID to generated wrapper class name.</summary>
internal Dictionary<string, string> WrapperClassNames => _wrapperClassNames;
/// <summary>Gets the mapping of ATS type ID to the retained concrete type ID for its wrapper.</summary>
internal Dictionary<string, string> ConcreteTypeIds => _concreteTypeIds;
/// <summary>Gets the mapping of ATS type ID to the type reference it was resolved from.</summary>
internal Dictionary<string, AtsTypeRef> TypeRefsById => _typeRefsById;
/// <summary>Gets the type IDs that have generated Promise wrappers.</summary>
internal HashSet<string> TypesWithPromiseWrappers => _typesWithPromiseWrappers;
/// <summary>Gets the names of options interfaces that have been registered for generation.</summary>
internal HashSet<string> GeneratedOptionsInterfaces => _generatedOptionsInterfaces;
/// <summary>Gets the options interfaces to generate, keyed by interface name.</summary>
internal Dictionary<string, List<AtsParameterInfo>> OptionsInterfacesToGenerate => _optionsInterfacesToGenerate;
/// <summary>Gets the mapping of capability ID to the options interface name it uses.</summary>
internal Dictionary<string, string> CapabilityOptionsInterfaceMap => _capabilityOptionsInterfaceMap;
/// <summary>Gets the mapping of enum type ID to generated TypeScript enum name.</summary>
internal Dictionary<string, string> EnumTypeNames => _enumTypeNames;
/// <summary>Gets the XML documentation captured for handle types during ATS scanning.</summary>
internal Dictionary<string, AtsDocumentationInfo> HandleDocumentationById => _handleDocumentationById;
/// <summary>Gets the DTO metadata used for generated argument marshalling.</summary>
internal Dictionary<string, AtsDtoTypeInfo> DtoTypesById => _dtoTypesById;
private TypeScriptResolvedModel Resolve(AtsContext context)
{
var capabilities = context.Capabilities;
var dtoTypes = context.DtoTypes;
var directlyReturnedResourceTypesByClassName = capabilities
.Where(capability => capability.CapabilityKind != AtsCapabilityKind.PropertySetter)
.Select(capability => capability.ReturnType)
.Where(typeRef => typeRef?.IsResourceBuilder == true)
.Select(typeRef => typeRef!)
.DistinctBy(typeRef => typeRef.TypeId, StringComparer.Ordinal)
.GroupBy(typeRef => DeriveClassName(typeRef.TypeId), StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.ToList(), StringComparer.Ordinal);
var builders = CreateBuilderModels(capabilities);
var clientMethods = GetEntryPointCapabilities(capabilities)
.Where(c => string.IsNullOrEmpty(c.TargetTypeId))
.ToList();
// Collect all unique type IDs for handle type aliases.
// Exclude DTO types - they have their own interfaces, not handle aliases.
var dtoTypeIds = new HashSet<string>(dtoTypes.Select(d => d.TypeId), StringComparer.Ordinal);
var typeIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var typeId in CollectAllReferencedTypes(capabilities).Keys)
{
if (!dtoTypeIds.Contains(typeId))
{
typeIds.Add(typeId);
}
}
// Ensure all builder type IDs have handle type aliases.
// CreateBuilderModels discovers additional resource types via CollectAllReferencedTypes
// (e.g. types that appear only in return types or parameters but aren't direct capability targets).
// Without this, the builder class references a handle type that was never declared.
foreach (var builder in builders)
{
if (!dtoTypeIds.Contains(builder.TypeId))
{
typeIds.Add(builder.TypeId);
}
}
// Separate builders into categories:
// 1. Resource builders: IResource*, ContainerResource, etc.
// 2. Type classes: everything else (context types, wrapper types)
var resourceBuilders = builders.Where(b => b.TargetType?.IsResourceBuilder == true).ToList();
var typeClasses = builders.Where(b => b.TargetType?.IsResourceBuilder != true).ToList();
// Build wrapper class name mapping before anything consumes the mappings so callback
// properties can reference wrapper classes instead of raw handle aliases.
_wrapperClassNames.Clear();
_concreteTypeIds.Clear();
_typeRefsById.Clear();
_typesWithPromiseWrappers.Clear();
_generatedOptionsInterfaces.Clear();
_optionsInterfacesToGenerate.Clear();
_capabilityOptionsInterfaceMap.Clear();
_optionsInterfaceOwningAssemblies.Clear();
_handleDocumentationById.Clear();
_dtoTypesById.Clear();
_enumTypeNames.Clear();
foreach (var dtoType in dtoTypes)
{
_dtoTypesById[dtoType.TypeId] = dtoType;
}
foreach (var handleType in context.HandleTypes)
{
if (handleType.Documentation is not null)
{
_handleDocumentationById[handleType.AtsTypeId] = handleType.Documentation;
}
}
foreach (var builder in resourceBuilders)
{
_wrapperClassNames[builder.TypeId] = builder.BuilderClassName;
_concreteTypeIds[builder.TypeId] = builder.TypeId;
if (builder.TargetType is { } targetType)
{
_typeRefsById[builder.TypeId] = targetType;
}
directlyReturnedResourceTypesByClassName.TryGetValue(builder.BuilderClassName, out var directlyReturnedAliases);
// Builder models are deduplicated by generated class name, so the retained TypeId may
// differ from a directly returned interface TypeId. Register the retained TypeId to emit
// one declaration pair and every returned alias so return sites resolve to that pair.
if (HasChainableMethods(builder) || directlyReturnedAliases is not null)
{
_typesWithPromiseWrappers.Add(builder.TypeId);
if (directlyReturnedAliases is not null)
{
foreach (var alias in directlyReturnedAliases)
{
_typesWithPromiseWrappers.Add(alias.TypeId);
_wrapperClassNames[alias.TypeId] = builder.BuilderClassName;
_concreteTypeIds[alias.TypeId] = builder.TypeId;
_typeRefsById[alias.TypeId] = builder.TargetType ?? alias;
}
}
}
}
foreach (var typeClass in typeClasses)
{
_wrapperClassNames[typeClass.TypeId] = DeriveClassName(typeClass.TypeId);
_concreteTypeIds[typeClass.TypeId] = typeClass.TypeId;
if (typeClass.TargetType is { } targetType)
{
_typeRefsById[typeClass.TypeId] = targetType;
}
// Type classes with methods get Promise wrappers
if (HasChainableMethods(typeClass))
{
_typesWithPromiseWrappers.Add(typeClass.TypeId);
}
}
// InteractionInputCollection is a hand-written base.mts type: its by-name accessors
// (value/get/required/requiredValue) are client-side conveniences, not ATS capabilities, so
// it is never registered as a generated type class. Register it as a promise-wrapper type so
// collection-returning getters (result.inputs(), validationContext.inputs(), command
// arguments()) emit the fluent InteractionInputCollectionPromise thenable instead of a bare
// Promise<InteractionInputCollection>. That lets callers chain `await x.inputs().value("c")`
// without an intermediate await, matching the C#/Go/Java/Python surfaces. The wrapper
// (InteractionInputCollectionPromise / InteractionInputCollectionPromiseImpl) is hand-written
// in base.mts; it is intentionally absent from the wrapper class table so the getter impl
// keeps using the marshaller-based collection construction rather than a handle+Impl wrapper.
_typesWithPromiseWrappers.Add(InteractionInputCollectionTypeId);
// Note: ReferenceExpression is intentionally NOT added to the wrapper class table.
// It is a value type defined in base.mts with a private constructor and static factory,
// not a handle-based wrapper. It is handled via MapTypeRefToTypeScript instead.
// Enum names are a resolution decision, not an emission detail: MapEnumType has to resolve
// them while options interfaces are being registered, which happens before any enum is
// written out.
_enumTypeNames[InputTypeTypeId] = GetInputTypeEnumName();
foreach (var enumType in context.EnumTypes.Where(e => e.TypeId != InputTypeTypeId))
{
_enumTypeNames[enumType.TypeId] = enumType.Name;
}
// Pre-scan all capabilities to collect options interfaces.
// This must happen AFTER wrapper class names are populated so types resolve correctly.
// Options names are public TypeScript API. Allocate collision suffixes after sorting by the
// stable capability identity so a combined context produces byte-identical output regardless
// of the order in which package capabilities were discovered.
foreach (var cap in builders
.SelectMany(builder => builder.Capabilities)
.OrderBy(capability => capability.CapabilityId, StringComparer.Ordinal))
{
var (_, optionalParams) = SeparateParameters(cap.Parameters);
if (optionalParams.Count > 0 && !TryGetDirectOptionsParameter(optionalParams, out _))
{
RegisterOptionsInterface(cap.CapabilityId, cap.MethodName, optionalParams, GetCapabilityOwningAssemblyName(context, cap));
}
}
return new TypeScriptResolvedModel
{
Context = context,
Builders = builders,
ResourceBuilders = resourceBuilders,
TypeClasses = typeClasses,
ClientMethods = clientMethods,
HandleTypeIds = typeIds
};
}
/// <summary>
/// Resolves the public signature of a capability exactly once so the source emitter and the
/// canonical exporter cannot disagree about parameter shaping or return type selection.
/// </summary>
/// <param name="builder">The builder the capability is rendered on, or <see langword="null"/> for a client entry point.</param>
/// <param name="capability">The capability to resolve.</param>
/// <remarks>
/// Resource builders and type classes shape methods differently: they bind a different default
/// target parameter name, derive the method name differently, and pick fluent return types by
/// different rules. Both rules live here so neither emitter has to reimplement them.
/// </remarks>
internal TypeScriptApiMethodSignature ResolveMethodSignature(BuilderModel? builder, AtsCapabilityInfo capability)
{
ArgumentNullException.ThrowIfNull(capability);
var isTypeClass = builder is not null && builder.TargetType?.IsResourceBuilder != true;
var targetParamName = capability.TargetParameterName ?? (isTypeClass ? "context" : "builder");
var userParams = builder is null
? [.. capability.Parameters]
: capability.Parameters.Where(p => p.Name != targetParamName).ToList();
var (requiredParams, optionalParams) = SeparateParameters(userParams);
var hasOptionals = optionalParams.Count > 0;
var hasDirectOptionsParameter = TryGetDirectOptionsParameter(optionalParams, out var directOptionsParam);
var optionsTypeName = hasDirectOptionsParameter
? MapParameterToTypeScript(directOptionsParam!)
: ResolveOptionsInterfaceName(capability);
var optionsParameterName = GetPublicOptionsParameterName(userParams, hasOptionals, hasDirectOptionsParameter);
var trailingCancellationToken = GetTrailingCancellationTokenParameter(optionalParams);
var publicParameters = requiredParams
.Select(ProjectPublicParameter)
.ToList();
TypeScriptApiParameter? optionsParameter = null;
if (hasOptionals)
{
optionsParameter = new TypeScriptApiParameter
{
Name = optionsParameterName,
DeclaredType = optionsTypeName,
IsOptional = true,
Summary = directOptionsParam?.Documentation?.Summary
};
publicParameters.Add(optionsParameter);
}
TypeScriptApiParameter? publicCancellationToken = null;
if (trailingCancellationToken is not null)
{
publicCancellationToken = ProjectPublicParameter(trailingCancellationToken);
publicParameters.Add(publicCancellationToken);
}
return new TypeScriptApiMethodSignature
{
MethodName = isTypeClass ? ResolveTypeClassMethodName(capability) : capability.MethodName,
ReturnType = isTypeClass
? ResolveTypeClassReturnType(builder!, capability)
: ResolveBuilderReturnType(builder, capability),
Parameters = publicParameters,
RequiredParameters = requiredParams,
OptionsParameter = optionsParameter,
TrailingCancellationToken = publicCancellationToken
};
TypeScriptApiParameter ProjectPublicParameter(AtsParameterInfo parameter)
=> new()
{
Name = parameter.Name,
DeclaredType = MapParameterToTypeScript(parameter),
IsOptional = parameter.IsOptional || parameter.IsNullable,
Summary = parameter.Documentation?.Summary
};
}
/// <summary>
/// Strips the declaring type prefix from an explicitly implemented member.
/// </summary>
/// <remarks>
/// Capabilities on an interface implementation carry the qualified C# name, for example
/// <c>IValueProvider.GetValueAsync</c>. TypeScript has no explicit interface implementation, so
/// only the trailing member name is emitted.
/// </remarks>
private static string ResolveTypeClassMethodName(AtsCapabilityInfo capability)
=> !string.IsNullOrEmpty(capability.OwningTypeName) && capability.MethodName.Contains('.')
? capability.MethodName[(capability.MethodName.LastIndexOf('.') + 1)..]
: GetTypeScriptMethodName(capability.MethodName);
/// <summary>
/// Selects the return type for a method on a resource builder: a promise wrapper when the
/// non-builder return type has one, a plain <c>Promise<T></c> when it does not, and the
/// owning builder's fluent promise interface when the method chains.
/// </summary>
private string ResolveBuilderReturnType(BuilderModel? builder, AtsCapabilityInfo capability)
{
var hasNonBuilderReturn = !capability.ReturnsBuilder && capability.ReturnType is not null;
if (hasNonBuilderReturn)
{
return TryGetPromiseWrapperType(capability.ReturnType, out var promiseInterfaceName, out _)
? promiseInterfaceName
: $"Promise<{MapTypeRefToTypeScript(capability.ReturnType)}>";
}
if (builder is not null)
{
return GetBuilderPromiseInterfaceForMethod(builder, capability);
}
// Entry points have no owning builder, so the fluent return comes from the return type itself.
return capability.ReturnType is { TypeId: { } returnTypeId }
? GetPublicPromiseInterfaceName(returnTypeId)
: "Promise<void>";
}
/// <summary>
/// Selects the return type for a method on a type class. Void-returning methods chain on the
/// owning class rather than resolving to <c>Promise<void></c>, which is what makes context
/// types fluent.
/// </summary>
private string ResolveTypeClassReturnType(BuilderModel builder, AtsCapabilityInfo capability)
{
if (capability.ReturnType is { } returnType && _typesWithPromiseWrappers.Contains(returnType.TypeId))
{
return GetPublicPromiseInterfaceName(returnType.TypeId);
}
if (capability.ReturnType is null || capability.ReturnType.TypeId == AtsConstants.Void)
{
return GetPromiseInterfaceName(DeriveClassName(builder.TypeId));
}
return $"Promise<{MapTypeRefToTypeScript(capability.ReturnType)}>";
}
/// <summary>
/// Builds the canonical API export model for one package from the already-resolved projection.
/// </summary>
/// <remarks>
/// Declaration fragment IDs are local to <paramref name="package"/>. Their canonical identity is
/// <c>(package.name, package.version, declaration.id)</c>; consumers must not flatten declarations
/// from separate package exports because their package-local TypeScript names can overlap.
/// </remarks>
/// <param name="package">The exact package identity the export is produced for.</param>
/// <param name="ownedAssemblyNames">
/// The assemblies whose symbols the package owns. Symbols outside this set reached the context
/// through the referenced-type closure: they contribute declaration fragments so the export
/// type-checks, but they must not produce documentation pages here.
/// </param>
/// <param name="cancellationToken">A token to cancel the export between projected items.</param>
internal TypeScriptApiModel BuildApiModel(
TypeScriptApiPackageIdentity package,
IReadOnlyCollection<string> ownedAssemblyNames,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(package);
ArgumentNullException.ThrowIfNull(ownedAssemblyNames);
cancellationToken.ThrowIfCancellationRequested();
var owned = new HashSet<string>(ownedAssemblyNames, StringComparer.OrdinalIgnoreCase);
var items = new List<TypeScriptApiItem>();
var declarations = new Dictionary<string, TypeScriptApiDeclaration>(StringComparer.Ordinal)
{
[RuntimeDeclarationId] = new TypeScriptApiDeclaration
{
Id = RuntimeDeclarationId,
Content = RuntimeDeclarationContent,
OwningAssemblyName = "Aspire.Hosting"
}
};
foreach (var builderModel in _resolved.Builders.OrderBy(b => b.BuilderClassName, StringComparer.Ordinal))
{
cancellationToken.ThrowIfCancellationRequested();
var (item, builderDeclarations) = ProjectBuilder(package, builderModel, owned);
foreach (var declaration in builderDeclarations)
{
declarations[declaration.Id] = declaration;
}
if (item is not null)
{
items.Add(item);
}
}
foreach (var entryPoint in _resolved.ClientMethods.OrderBy(c => c.MethodName, StringComparer.Ordinal))
{
cancellationToken.ThrowIfCancellationRequested();
if (!owned.Contains(GetCapabilityOwningAssemblyName(entryPoint)))
{
continue;
}
var (item, declaration) = ProjectEntryPoint(entryPoint);
items.Add(item);
declarations[declaration.Id] = declaration;
}
foreach (var enumType in _resolved.Context.EnumTypes
.Where(e => e.TypeId != InputTypeTypeId)
.OrderBy(e => e.Name, StringComparer.Ordinal))
{
cancellationToken.ThrowIfCancellationRequested();
var (item, declaration) = ProjectEnum(enumType);
declarations[declaration.Id] = declaration;
if (owned.Contains(item.OwningAssemblyName))
{
items.Add(item);
}
}
foreach (var dtoType in _resolved.Context.DtoTypes
.Where(d => d.TypeId != InteractionInputTypeId)
.OrderBy(d => d.TypeId, StringComparer.Ordinal))
{
cancellationToken.ThrowIfCancellationRequested();
var (item, declaration) = ProjectDto(dtoType);
declarations[declaration.Id] = declaration;
if (owned.Contains(item.OwningAssemblyName))
{
items.Add(item);
}
}
var exportedValues = _resolved.Context.ExportedValues
.Where(value => owned.Contains(value.OwningAssemblyName))
.ToList();
foreach (var exportedNamespace in ProjectExportedValues(exportedValues))
{
cancellationToken.ThrowIfCancellationRequested();
var item = new TypeScriptApiItem
{
Id = $"namespace:{exportedNamespace.Name}",
TypeId = $"namespace:{exportedNamespace.Name}",
Kind = TypeScriptApiItemKind.Namespace,
Name = exportedNamespace.Name,
Declaration = $"export namespace {exportedNamespace.Name}",
OwningAssemblyName = package.Name,
Members = exportedNamespace.Members
};
var declaration = new TypeScriptApiDeclaration
{
Id = $"{package.Name}:namespace:{exportedNamespace.Name}",
Content = exportedNamespace.Content,
OwningAssemblyName = package.Name
};
items.Add(item);
declarations[declaration.Id] = declaration;
}
// Options interfaces belong to the assembly whose capability produced them, which is what
// both their fragment ID and their documented-item gate key off. Otherwise, a package could
// document options interfaces belonging to its dependencies.
foreach (var (interfaceName, optionalParams) in _optionsInterfacesToGenerate.OrderBy(kvp => kvp.Key, StringComparer.Ordinal))
{
cancellationToken.ThrowIfCancellationRequested();
var owningAssemblyName = _optionsInterfaceOwningAssemblies.GetValueOrDefault(interfaceName, package.Name);
var (item, declaration) = ProjectOptionsInterface(owningAssemblyName, interfaceName, optionalParams);
declarations[declaration.Id] = declaration;
if (owned.Contains(item.OwningAssemblyName))
{
items.Add(item);
}
}
// Types reached through the referenced-type closure are named by generated unions and
// parameters but have no capabilities of their own in this context, so nothing above
// declared them. Emit an opaque interface for each so this package's declarations type-check
// standalone. They deliberately produce no documented item: the package that owns them
// publishes their real surface.
// Deduplicate by declared name rather than by type ID: several ATS type IDs can resolve to
// the same generated interface name, and emitting a stub for one of them would redeclare a
// type another fragment already declares in full.
var declaredNames = new HashSet<string>(s_runtimeDeclaredNames, StringComparer.Ordinal);
foreach (var declaration in declarations.Values)
{
foreach (Match match in DeclaredTypeNameRegex().Matches(declaration.Content))
{
declaredNames.Add(match.Groups[1].Value);
}
}
foreach (var typeId in _resolved.HandleTypeIds.OrderBy(id => id, StringComparer.Ordinal))
{
cancellationToken.ThrowIfCancellationRequested();
var wrapperClassName = _wrapperClassNames.GetValueOrDefault(typeId);
var owningAssembly = GetTypeOwningAssemblyName(typeId);
// Handle types without a generated wrapper class surface in signatures under their raw
// handle alias name, so the fragment has to declare that exact alias. Deriving a class
// name here instead would declare a symbol no signature ever references and leave the
// referenced one undefined.
if (wrapperClassName is null)
{
var handleName = GetHandleTypeName(typeId);
if (declaredNames.Add(handleName))
{
declarations[$"{owningAssembly}:handle:{handleName}"] = new TypeScriptApiDeclaration
{
Id = $"{owningAssembly}:handle:{handleName}",
Content = $"export type {handleName} = Handle<'{typeId}'>;",
OwningAssemblyName = owningAssembly
};
}
continue;
}
var name = GetInterfaceName(wrapperClassName);
if (!declaredNames.Add(name))
{
continue;
}
var baseType = _typeRefsById.GetValueOrDefault(typeId)?.IsResourceBuilder == true
? "ResourceBuilderBase"
: "HandleReference";
declarations[$"{owningAssembly}:opaque:{name}"] = new TypeScriptApiDeclaration
{
Id = $"{owningAssembly}:opaque:{name}",
Content = $"export interface {name} extends {baseType} {{}}",
OwningAssemblyName = owningAssembly
};
if (!_typesWithPromiseWrappers.Contains(typeId))
{
continue;
}
var promiseName = GetPromiseInterfaceName(wrapperClassName);
if (!declaredNames.Add(promiseName))
{
continue;
}
declarations[$"{owningAssembly}:opaque:{promiseName}"] = new TypeScriptApiDeclaration
{
Id = $"{owningAssembly}:opaque:{promiseName}",
Content = $"export interface {promiseName} extends PromiseLike<{name}> {{}}",
OwningAssemblyName = owningAssembly
};
}
var module = new TypeScriptApiModule
{
Name = package.Name,
Summary = null,
Items = [.. items.OrderBy(i => i.Id, StringComparer.Ordinal)]
};
return new TypeScriptApiModel
{
SchemaVersion = ExportSchemaVersion,
Language = "typescript",
Generator = s_generatorIdentity,
Package = package,
Modules = [module],
Declarations = [.. declarations.Values.OrderBy(d => d.Id, StringComparer.Ordinal)]
};
}
/// <summary>
/// Projects exported values into namespace declarations shared by source generation and API export.
/// </summary>
/// <param name="exportedValues">The values to project.</param>
/// <returns>The rendered top-level namespaces and their canonical members.</returns>
internal IReadOnlyList<TypeScriptExportedValueNamespace> ProjectExportedValues(
IReadOnlyList<AtsExportedValueInfo> exportedValues)
{
var root = BuildExportedValueTree(exportedValues);
var namespaces = new List<TypeScriptExportedValueNamespace>();
foreach (var (name, node) in root.Children.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
var content = new StringBuilder();
var members = new List<TypeScriptApiMember>();
content.Append("export namespace ").Append(name).Append(" {\n");
AppendExportedValueChildren(content, node, [name], members, indentLevel: 1);
content.Append('}');
namespaces.Add(new TypeScriptExportedValueNamespace
{
Name = name,
Content = content.ToString(),
Members = members
});
}
return namespaces;
}
private void AppendExportedValueChildren(
StringBuilder content,
ExportedValueTreeNode node,
IReadOnlyList<string> parentPath,
List<TypeScriptApiMember> members,
int indentLevel)
{
var indent = new string(' ', indentLevel * 4);
foreach (var (name, child) in node.Children.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
var path = parentPath.Append(name).ToArray();
if (child.Value is { } valueInfo)
{
foreach (var documentationLine in RenderDocumentationComment(
indent,
valueInfo.Documentation,
valueInfo.Description))
{
content.Append(documentationLine).Append('\n');
}
var declaration = $"export const {name} = {RenderTypeScriptExportedValueExpression(valueInfo)}";
content.Append(indent).Append(declaration).Append(";\n");
members.Add(new TypeScriptApiMember
{
Id = $"constant:{string.Join(".", path)}",
Kind = TypeScriptApiItemKind.Constant,
Name = name,
Declaration = declaration,
Summary = valueInfo.Documentation?.Summary ?? valueInfo.Description,
Remarks = valueInfo.Documentation?.Remarks,
OwningAssemblyName = valueInfo.OwningAssemblyName
});
}
else
{
var declaration = $"export namespace {name}";
content.Append(indent).Append(declaration).Append(" {\n");
members.Add(new TypeScriptApiMember
{
Id = $"namespace:{string.Join(".", path)}",
Kind = TypeScriptApiItemKind.Namespace,
Name = name,
Declaration = declaration
});
AppendExportedValueChildren(content, child, path, members, indentLevel + 1);
content.Append(indent).Append("}\n");
}
content.Append('\n');
}
}
private string RenderTypeScriptExportedValueExpression(AtsExportedValueInfo exportedValue)
{
var literal = RenderTypeScriptExportedValue(exportedValue.Value, exportedValue.Type);
var exportedType = MapTypeRefToTypeScript(exportedValue.Type);
return exportedValue.Type.Category is AtsTypeCategory.Primitive
? literal
: $"{literal} as {exportedType}";
}
private string RenderTypeScriptExportedValue(JsonNode? value, AtsTypeRef typeRef)
{
if (value is null)
{
return "null";
}
return typeRef.Category switch
{
AtsTypeCategory.Dto when value is JsonObject obj && _dtoTypesById.TryGetValue(typeRef.TypeId, out var dtoInfo)
=> RenderTypeScriptDtoValue(obj, dtoInfo),
AtsTypeCategory.Array or AtsTypeCategory.List when value is JsonArray arr
=> $"[{string.Join(", ", arr.Select(item => RenderTypeScriptExportedValue(item, typeRef.ElementType!)))}]",
AtsTypeCategory.Dict when value is JsonObject obj
=> "{ " + string.Join(", ", obj.Select(pair => $"{AtsJsonCodeWriter.ToRelaxedJsonString(pair.Key)}: {RenderTypeScriptExportedValue(pair.Value, typeRef.ValueType!)}")) + " }",
_ => value.ToRelaxedJsonString()
};
}
private string RenderTypeScriptDtoValue(JsonObject value, AtsDtoTypeInfo dtoInfo)
{
var members = new List<string>();
foreach (var property in dtoInfo.Properties)
{
if (value.TryGetPropertyValue(property.Name, out var propertyValue))
{
members.Add($"{ToCamelCase(property.Name)}: {RenderTypeScriptExportedValue(propertyValue, property.Type)}");
}
}
return "{ " + string.Join(", ", members) + " }";
}
private static IReadOnlyList<string> RenderDocumentationComment(
string indent,
AtsDocumentationInfo? documentation,
string? fallbackSummary)
{
var lines = new List<string>();
AddDocumentationLines(lines, documentation?.Summary ?? fallbackSummary);
AddDocumentationLines(lines, documentation?.Remarks, addBlankLineBefore: lines.Count > 0);
AddTaggedDocumentationLines(lines, "@returns", documentation?.Returns);
if (lines.Count == 0)
{
return [];
}
if (lines.Count == 1 && !lines[0].StartsWith('@'))
{
return [$"{indent}/** {lines[0]} */"];
}
var comment = new List<string> { $"{indent}/**" };
comment.AddRange(lines.Select(line => line.Length == 0 ? $"{indent} *" : $"{indent} * {line}"));
comment.Add($"{indent} */");
return comment;
}
private static void AddTaggedDocumentationLines(List<string> lines, string tag, string? text)
{
var tagLines = SplitDocumentationLines(text);
if (tagLines.Count == 0)
{
return;
}
lines.Add($"{tag} {tagLines[0]}");
lines.AddRange(tagLines.Skip(1));
}
private static void AddDocumentationLines(List<string> lines, string? text, bool addBlankLineBefore = false)
{
var textLines = SplitDocumentationLines(text);
if (textLines.Count == 0)
{
return;
}
if (addBlankLineBefore)
{
lines.Add(string.Empty);
}
lines.AddRange(textLines);
}
private static List<string> SplitDocumentationLines(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return [];
}
return text
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n')
.Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.Select(EscapeJSDocText)
.ToList();
}
private static string EscapeJSDocText(string text) =>
ConvertAtsReferencesToJsDocLinks(text).Replace("*/", "* /", StringComparison.Ordinal);
private static string ConvertAtsReferencesToJsDocLinks(string text)
{
const string markerStart = "{@ats-ref ";
var startIndex = text.IndexOf(markerStart, StringComparison.Ordinal);
if (startIndex < 0)
{
return text;
}
var builder = new StringBuilder(text.Length);
var currentIndex = 0;
while (startIndex >= 0)
{
builder.Append(text, currentIndex, startIndex - currentIndex);
var markerBodyStartIndex = startIndex + markerStart.Length;
var markerEndIndex = text.IndexOf('}', markerBodyStartIndex);
if (markerEndIndex < 0)
{
builder.Append(text, startIndex, text.Length - startIndex);
return builder.ToString();
}
var markerBody = text[markerBodyStartIndex..markerEndIndex];
var labelSeparatorIndex = markerBody.IndexOf('|', StringComparison.Ordinal);
var reference = labelSeparatorIndex < 0 ? markerBody : markerBody[..labelSeparatorIndex];
var label = labelSeparatorIndex < 0 ? null : markerBody[(labelSeparatorIndex + 1)..];
var targetSeparatorIndex = reference.IndexOf(':', StringComparison.Ordinal);
if (targetSeparatorIndex < 0 || targetSeparatorIndex == reference.Length - 1)
{
builder.Append(text, startIndex, markerEndIndex - startIndex + 1);
}
else
{
var target = reference[(targetSeparatorIndex + 1)..];
builder.Append("{@link ").Append(target);
if (!string.IsNullOrWhiteSpace(label))
{
builder.Append('|').Append(label);
}
builder.Append('}');
}
currentIndex = markerEndIndex + 1;
startIndex = text.IndexOf(markerStart, currentIndex, StringComparison.Ordinal);
}
builder.Append(text, currentIndex, text.Length - currentIndex);
return builder.ToString();
}
private static ExportedValueTreeNode BuildExportedValueTree(IReadOnlyList<AtsExportedValueInfo> exportedValues)
{
var root = new ExportedValueTreeNode();
foreach (var exportedValue in exportedValues)
{
var current = root;
foreach (var segment in exportedValue.PathSegments)
{
if (!current.Children.TryGetValue(segment, out var child))
{
child = new ExportedValueTreeNode();
current.Children[segment] = child;
}
current = child;
}
current.Value = exportedValue;
}
return root;
}
private static TypeScriptApiGeneratorIdentity CreateGeneratorIdentity()
{
var assembly = typeof(TypeScriptApiProjector).Assembly;
var version = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? throw new InvalidOperationException(
$"The '{assembly.GetName().Name}' assembly has no informational version.");
return new TypeScriptApiGeneratorIdentity(assembly.GetName().Name!, version);
}
/// <summary>
/// Projects one builder into an optional documented item plus the declaration fragments it
/// contributes.
/// </summary>
/// <remarks>
/// A package can extend a type another package owns. When that happens the type itself is not
/// documented here — the owning package publishes it — but the members this package contributes
/// still are. They are emitted as a separate interface augmentation fragment so TypeScript
/// declaration merging reassembles the referenced stub and this package's contributed surface.
/// </remarks>
private (TypeScriptApiItem? Item, List<TypeScriptApiDeclaration> Declarations) ProjectBuilder(
TypeScriptApiPackageIdentity package,
BuilderModel builderModel,
HashSet<string> ownedAssemblyNames)
{
var isResourceBuilder = builderModel.TargetType?.IsResourceBuilder == true;
var interfaceName = GetInterfaceName(isResourceBuilder
? builderModel.BuilderClassName
: DeriveClassName(builderModel.TypeId));
var members = new List<TypeScriptApiMember>();
var exportedCapabilities = builderModel.Capabilities
.Where(capability => ownedAssemblyNames.Contains(GetCapabilityOwningAssemblyName(capability)))
.ToList();
var promiseMembers = new List<TypeScriptApiMember>();
var getters = exportedCapabilities.Where(c => c.CapabilityKind == AtsCapabilityKind.PropertyGetter).ToList();
var setters = exportedCapabilities.Where(c => c.CapabilityKind == AtsCapabilityKind.PropertySetter).ToList();
foreach (var property in GroupPropertiesByName(getters, setters))
{
var member = ProjectProperty(interfaceName, property.PropertyName, property.Getter, property.Setter);
members.Add(member);
if (IsGetterOnlyProperty(property.Getter, property.Setter))
{
promiseMembers.Add(member);
}
}
// Type classes only surface instance and static methods; resource builders surface every
// non-property capability. Mirroring that split keeps the export aligned with the interfaces
// the generator actually writes.
var methods = isResourceBuilder
? exportedCapabilities.Where(c =>
c.CapabilityKind != AtsCapabilityKind.PropertyGetter &&
c.CapabilityKind != AtsCapabilityKind.PropertySetter)
: exportedCapabilities.Where(c =>
c.CapabilityKind is AtsCapabilityKind.InstanceMethod or AtsCapabilityKind.Method);
foreach (var capability in methods)
{
var member = ProjectMethod(interfaceName, builderModel, capability);
members.Add(member);
promiseMembers.Add(member);
}
var documentation = _handleDocumentationById.GetValueOrDefault(builderModel.TypeId);
string[] extends = isResourceBuilder ? ["ResourceBuilderBase"] : [];
var typeOwner = GetTypeOwningAssemblyName(builderModel.TypeId);
var declarations = new List<TypeScriptApiDeclaration>();
// Every method returns the owning type's fluent promise interface, so the promise interface
// has to be declared alongside the interface or the fragments cannot type-check.
var promiseInterfaceName = _typesWithPromiseWrappers.Contains(builderModel.TypeId)
? GetPromiseInterfaceName(isResourceBuilder ? builderModel.BuilderClassName : DeriveClassName(builderModel.TypeId))
: null;
if (ownedAssemblyNames.Contains(typeOwner))
{
declarations.Add(new TypeScriptApiDeclaration
{
Id = $"{typeOwner}:interface:{interfaceName}",
Content = BuildInterfaceBody(interfaceName, extends, members, includeToJson: true),
OwningAssemblyName = typeOwner
});
if (promiseInterfaceName is not null)
{
declarations.Add(new TypeScriptApiDeclaration
{
Id = $"{typeOwner}:interface:{promiseInterfaceName}",
Content = BuildInterfaceBody(promiseInterfaceName, [$"PromiseLike<{interfaceName}>"], promiseMembers, includeToJson: false),
OwningAssemblyName = typeOwner
});
}
return (BuildInterfaceItem(builderModel, $"interface:{interfaceName}", interfaceName, extends, typeOwner, documentation, members, TypeScriptApiItemKind.Interface), declarations);
}
// The referenced type gets one opaque stub keyed by its real owner within this package export.
declarations.Add(new TypeScriptApiDeclaration
{
Id = $"{typeOwner}:opaque:{interfaceName}",
Content = $"export interface {interfaceName} extends {(isResourceBuilder ? "ResourceBuilderBase" : "HandleReference")} {{}}",
OwningAssemblyName = typeOwner
});
if (promiseInterfaceName is not null)
{
declarations.Add(new TypeScriptApiDeclaration
{
Id = $"{typeOwner}:opaque:{promiseInterfaceName}",
Content = $"export interface {promiseInterfaceName} extends PromiseLike<{interfaceName}> {{}}",
OwningAssemblyName = typeOwner
});
}
if (members.Count == 0)
{
return (null, declarations);
}
declarations.Add(new TypeScriptApiDeclaration
{
Id = $"{package.Name}:augment:{interfaceName}",
Content = BuildInterfaceBody(interfaceName, [], members, includeToJson: false),
OwningAssemblyName = package.Name
});
if (promiseInterfaceName is not null)
{
declarations.Add(new TypeScriptApiDeclaration
{
Id = $"{package.Name}:augment:{promiseInterfaceName}",
Content = BuildInterfaceBody(promiseInterfaceName, [], promiseMembers, includeToJson: false),
OwningAssemblyName = package.Name
});
}
// The item carries the real owner and a distinct ID because it describes only this package's
// contribution, not a second copy of the referenced type. Include the contributing package
// because an aggregate export can contain several augmentations for the same interface name.
return (BuildInterfaceItem(builderModel, $"augmentation:{package.Name}:{interfaceName}", interfaceName, extends, typeOwner, documentation, members, TypeScriptApiItemKind.Augmentation), declarations);
}
private static TypeScriptApiItem BuildInterfaceItem(
BuilderModel builderModel,
string id,
string interfaceName,
string[] extends,
string owningAssemblyName,
AtsDocumentationInfo? documentation,
List<TypeScriptApiMember> members,
TypeScriptApiItemKind kind)
=> new()
{
Id = id,
TypeId = builderModel.TypeId,
Kind = kind,
Name = interfaceName,
Declaration = BuildInterfaceHeader(interfaceName, extends),
OwningAssemblyName = owningAssemblyName,
Summary = documentation?.Summary,
Remarks = documentation?.Remarks,
Extends = extends,
Members = members
};
/// <summary>
/// Matches the name a declaration fragment declares, for example the <c>RedisResource</c> in
/// <c>export interface RedisResource extends ResourceBuilderBase {</c>.
/// </summary>
/// <remarks>
/// <c>$</c> is matched as well as <c>\w</c> because package-qualified options interfaces embed
/// it as the qualifier terminator, and capturing only the qualifier would leave the real name
/// out of the declared set.
/// </remarks>
[GeneratedRegex(@"^export (?:interface|enum|type) ([\w$]+)", RegexOptions.Multiline)]
private static partial Regex DeclaredTypeNameRegex();
private static string BuildInterfaceBody(
string interfaceName,
IReadOnlyList<string> extends,
List<TypeScriptApiMember> members,
bool includeToJson)
{
var body = new StringBuilder();
body.Append(BuildInterfaceHeader(interfaceName, extends)).Append(" {\n");
if (includeToJson)
{
body.Append(" toJSON(): MarshalledHandle;\n");
}
foreach (var member in members)
{
body.Append(" ").Append(member.Declaration).Append(";\n");
}
return body.Append('}').ToString();
}
private TypeScriptApiMember ProjectMethod(
string ownerName,
BuilderModel? builderModel,
AtsCapabilityInfo capability)
{
var signature = ResolveMethodSignature(builderModel, capability);
return new TypeScriptApiMember
{
Id = $"method:{ownerName}.{capability.MethodName}",
Kind = TypeScriptApiItemKind.Method,
Name = signature.MethodName,
Declaration = signature.Declaration,
Summary = capability.Documentation?.Summary,
Remarks = capability.Documentation?.Remarks,
DeprecationMessage = capability.IsObsolete ? capability.ObsoleteMessage ?? string.Empty : null,
CapabilityId = capability.CapabilityId,
OwningAssemblyName = GetCapabilityOwningAssemblyName(capability),
Parameters = signature.Parameters,
ReturnType = signature.ReturnType
};
}
private TypeScriptApiMember ProjectProperty(
string ownerName,
string propertyName,
AtsCapabilityInfo? getter,
AtsCapabilityInfo? setter)
{
string declaration;
if (IsGetterOnlyProperty(getter, setter))
{
declaration = $"{propertyName}(): {GetGetterOnlyPropertyMethodReturnType(getter!.ReturnType)}";
}
else if (getter?.ReturnType is { } returnType && IsDictionaryType(returnType))
{
var keyType = returnType.KeyType is not null ? MapTypeRefToTypeScript(returnType.KeyType) : "string";
var valueType = returnType.ValueType is not null ? MapTypeRefToTypeScript(returnType.ValueType) : "unknown";
declaration = $"readonly {propertyName}: AspireDict<{keyType}, {valueType}>";
}
else if (getter?.ReturnType is { } listReturnType && IsListType(listReturnType))
{
var elementType = listReturnType.ElementType is not null ? MapTypeRefToTypeScript(listReturnType.ElementType) : "unknown";
declaration = $"readonly {propertyName}: AspireList<{elementType}>";
}
else
{
var accessors = new List<string>();
if (getter is not null)
{
var getReturn = TryGetPromiseWrapperType(getter.ReturnType, out var promiseInterfaceName, out _)
? promiseInterfaceName
: $"Promise<{MapTypeRefToTypeScript(getter.ReturnType)}>";
accessors.Add($"get: () => {getReturn}");
}
if (setter?.Parameters.FirstOrDefault(p => p.Name == "value") is { } valueParam)
{
accessors.Add($"set: (value: {MapInputTypeToTypeScript(valueParam.Type)}) => Promise<void>");
}
declaration = $"{propertyName}: {{ {string.Join("; ", accessors)} }}";
}
var documentation = getter?.Documentation ?? setter?.Documentation;
return new TypeScriptApiMember
{
Id = $"property:{ownerName}.{propertyName}",
Kind = TypeScriptApiItemKind.Property,
Name = propertyName,
Declaration = declaration,
Summary = documentation?.Summary,
Remarks = documentation?.Remarks,
DeprecationMessage = (getter ?? setter) is { IsObsolete: true } obsolete ? obsolete.ObsoleteMessage ?? string.Empty : null,
CapabilityId = (getter ?? setter)?.CapabilityId,
OwningAssemblyName = (getter ?? setter) is { } capability ? GetCapabilityOwningAssemblyName(capability) : null
};
}
private (TypeScriptApiItem Item, TypeScriptApiDeclaration Declaration) ProjectEntryPoint(AtsCapabilityInfo capability)
{
var signature = ResolveEntryPointSignature(capability);
var owningAssemblyName = GetCapabilityOwningAssemblyName(capability);
var item = new TypeScriptApiItem
{
Id = $"entrypoint:{owningAssemblyName}:{signature.MethodName}",
TypeId = capability.CapabilityId,
Kind = TypeScriptApiItemKind.Method,
Name = signature.MethodName,
Declaration = $"function {signature.Declaration}",
OwningAssemblyName = owningAssemblyName,
Summary = capability.Documentation?.Summary,
Remarks = capability.Documentation?.Remarks,
Members = []
};
return (item, new TypeScriptApiDeclaration
{
Id = $"{owningAssemblyName}:entrypoint:{signature.MethodName}",
Content = $"export declare {item.Declaration};",
OwningAssemblyName = owningAssemblyName
});
}
/// <summary>
/// Resolves the signature of an entry-point capability -- one that hangs off the client rather
/// than a builder type -- for both the emitted function and the exported declaration.
/// </summary>
/// <remarks>
/// <para>
/// Entry points are shaped unlike every other capability, which is why they cannot share
/// <see cref="ResolveMethodSignature"/>. They are free functions rather than members, so the
/// client has to be passed explicitly as the first parameter, and their optional arguments stay
/// positional instead of collapsing into an options bag.
/// </para>
/// <para>
/// Routing <see cref="ProjectEntryPoint"/> through <see cref="ResolveMethodSignature"/> gave the
/// export the member shape -- no <c>client</c>, optionals folded into an options interface --
/// while <c>GenerateEntryPointFunction</c> emitted the free-function shape. Consumers type-check
/// the exported declarations against the generated SDK, so the two disagreeing produced
/// declarations that did not describe any callable function.
/// </para>
/// </remarks>
internal TypeScriptApiMethodSignature ResolveEntryPointSignature(AtsCapabilityInfo capability)
{
ArgumentNullException.ThrowIfNull(capability);
var (requiredParameters, _) = SeparateParameters(capability.Parameters);
var parameters = new List<TypeScriptApiParameter>
{
new() { Name = EntryPointClientParameterName, DeclaredType = EntryPointClientParameterType, IsOptional = false }
};
foreach (var parameter in capability.Parameters)
{
parameters.Add(new TypeScriptApiParameter
{
Name = parameter.Name,
DeclaredType = MapParameterToTypeScript(parameter),
IsOptional = parameter.IsOptional || parameter.IsNullable,
Summary = parameter.Documentation?.Summary
});
}
return new TypeScriptApiMethodSignature
{
MethodName = capability.MethodName,
ReturnType = ResolveEntryPointReturnType(capability),
Parameters = parameters,
RequiredParameters = requiredParameters
};
}
private string ResolveEntryPointReturnType(AtsCapabilityInfo capability)
{
var returnTypeId = capability.ReturnType?.TypeId;
// A capability that returns a wrapped handle is emitted as a fluent function returning the
// promise wrapper directly, so it is already thenable and is not wrapped again.
if (GetPromiseWrapperForReturnType(capability.ReturnType) is { } promiseWrapper && !string.IsNullOrEmpty(returnTypeId))
{
return promiseWrapper;
}
return $"Promise<{(string.IsNullOrEmpty(returnTypeId) ? "void" : MapTypeRefToTypeScript(capability.ReturnType))}>";
}
private static (TypeScriptApiItem Item, TypeScriptApiDeclaration Declaration) ProjectEnum(AtsEnumTypeInfo enumType)
{
var owningAssemblyName = GetOwningAssemblyName(enumType.TypeId, enumType.ClrType?.Assembly.GetName().Name);
var values = enumType.ValueInfos.Count > 0
? enumType.ValueInfos
: [.. enumType.Values.Select(value => new AtsEnumValueInfo { Name = value })];
var members = values
.Select(value => new TypeScriptApiMember
{
Id = $"enumValue:{enumType.Name}.{value.Name}",
Kind = TypeScriptApiItemKind.Property,
Name = value.Name,
Declaration = $"{value.Name} = \"{value.Name}\"",
Summary = value.Documentation?.Summary,
OwningAssemblyName = owningAssemblyName
})
.ToList();
var item = new TypeScriptApiItem
{
Id = $"enum:{enumType.Name}",
TypeId = enumType.TypeId,
Kind = TypeScriptApiItemKind.Enum,
Name = enumType.Name,
Declaration = $"export enum {enumType.Name}",
OwningAssemblyName = owningAssemblyName,
Summary = enumType.Documentation?.Summary,
Remarks = enumType.Documentation?.Remarks,
Members = members
};
var body = new StringBuilder();
body.Append("export enum ").Append(enumType.Name).Append(" {\n");
foreach (var member in members)
{
body.Append(" ").Append(member.Declaration).Append(",\n");
}
body.Append('}');
return (item, new TypeScriptApiDeclaration
{
Id = $"{item.OwningAssemblyName}:enum:{enumType.Name}",
Content = body.ToString(),
OwningAssemblyName = item.OwningAssemblyName
});
}
/// <summary>
/// Properties the TypeScript client adds to a DTO that has no C# counterpart. The emitter used to
/// own this list, so the exported interface described fewer properties than the module we actually
/// ship. Both paths read it from here now.
/// </summary>
private static readonly IReadOnlyDictionary<string, IReadOnlyList<ClientOnlyDtoProperty>> s_clientOnlyDtoProperties =
new Dictionary<string, IReadOnlyList<ClientOnlyDtoProperty>>(StringComparer.Ordinal)
{
["CreateBuilderOptions"] =
[
new ClientOnlyDtoProperty(
"throwOnPendingRejections",
"boolean",
"When false, pre-flush rejected promises are not re-thrown by build(). Default: true.")
]
};
internal static IReadOnlyList<ClientOnlyDtoProperty> GetClientOnlyDtoProperties(string interfaceName)
=> s_clientOnlyDtoProperties.TryGetValue(interfaceName, out var properties) ? properties : [];
private (TypeScriptApiItem Item, TypeScriptApiDeclaration Declaration) ProjectDto(AtsDtoTypeInfo dtoType)
{
var interfaceName = GetDtoInterfaceName(dtoType.TypeId);
var owningAssemblyName = GetOwningAssemblyName(dtoType.TypeId, dtoType.ClrType?.Assembly.GetName().Name);
var members = dtoType.Properties
.Select(property =>
{
var propertyName = ToCamelCase(property.Name);
var propertyType = property.IsCallback
? GenerateCallbackTypeSignature(property.CallbackParameters, property.CallbackReturnType)
: MapDtoPropertyTypeToTypeScript(property.Type);
return new TypeScriptApiMember
{
Id = $"property:{interfaceName}.{propertyName}",
Kind = TypeScriptApiItemKind.Property,
Name = propertyName,
Declaration = $"{propertyName}?: {propertyType}",
Summary = property.Documentation?.Summary ?? property.Description,
OwningAssemblyName = owningAssemblyName
};
})
.ToList();
members.AddRange(GetClientOnlyDtoProperties(interfaceName).Select(property => new TypeScriptApiMember
{
Id = $"property:{interfaceName}.{property.Name}",
Kind = TypeScriptApiItemKind.Property,
Name = property.Name,
Declaration = $"{property.Name}?: {property.Type}",
Summary = property.Summary,
OwningAssemblyName = owningAssemblyName
}));
var item = new TypeScriptApiItem
{
Id = $"dto:{interfaceName}",
TypeId = dtoType.TypeId,
Kind = TypeScriptApiItemKind.Dto,
Name = interfaceName,
Declaration = $"export interface {interfaceName}",
OwningAssemblyName = owningAssemblyName,
Summary = dtoType.Documentation?.Summary,
Remarks = dtoType.Documentation?.Remarks,
Members = members
};
var body = new StringBuilder();
body.Append("export interface ").Append(interfaceName).Append(" {\n");
foreach (var member in members)
{
body.Append(" ").Append(member.Declaration).Append(";\n");
}
body.Append('}');
return (item, new TypeScriptApiDeclaration
{
Id = $"{item.OwningAssemblyName}:dto:{interfaceName}",
Content = body.ToString(),
OwningAssemblyName = item.OwningAssemblyName
});
}
private (TypeScriptApiItem Item, TypeScriptApiDeclaration Declaration) ProjectOptionsInterface(
string owningAssemblyName,
string interfaceName,
List<AtsParameterInfo> optionalParams)
{
var members = optionalParams
.Select(param => new TypeScriptApiMember
{
Id = $"property:{interfaceName}.{param.Name}",
Kind = TypeScriptApiItemKind.Property,
Name = param.Name,
Declaration = $"{param.Name}?: {MapParameterToTypeScript(param)}",
Summary = param.Documentation?.Summary,
OwningAssemblyName = owningAssemblyName
})
.ToList();
var item = new TypeScriptApiItem
{
Id = $"options:{interfaceName}",
TypeId = $"{owningAssemblyName}/{interfaceName}",
Kind = TypeScriptApiItemKind.Options,
Name = interfaceName,
Declaration = $"export interface {interfaceName}",
OwningAssemblyName = owningAssemblyName,
Members = members
};
var body = new StringBuilder();
body.Append("export interface ").Append(interfaceName).Append(" {\n");
foreach (var member in members)
{
body.Append(" ").Append(member.Declaration).Append(";\n");
}
body.Append('}');
return (item, new TypeScriptApiDeclaration
{
Id = $"{owningAssemblyName}:options:{interfaceName}",
Content = body.ToString(),
OwningAssemblyName = owningAssemblyName
});
}
private static string BuildInterfaceHeader(string interfaceName, IReadOnlyList<string> extends)
=> extends.Count > 0
? $"export interface {interfaceName} extends {string.Join(", ", extends)}"
: $"export interface {interfaceName}";
/// <summary>
/// Resolves the owning assembly from the leading segment of an ATS identifier.
/// </summary>
/// <remarks>
/// ATS identifiers are <c>{Prefix}/{FullTypeNameOrMemberName}</c>, for example
/// <c>Aspire.Hosting.Redis/RedisResource</c> or <c>Aspire.Hosting.Redis/addRedis</c>. The prefix
/// is usually the assembly name, but instance members carry the declaring namespace instead
/// (<c>Contoso.Widgets.Model/WidgetContext.name</c>), so this is only a fallback for symbols
/// that carry no CLR reflection info. Enum type IDs use the <c>enum:</c> prefix and have no
/// segment at all, so the caller supplies the CLR assembly name.
/// </remarks>
private static string GetOwningAssemblyName(string atsId, string? clrAssemblyName = null)
{
if (clrAssemblyName is { Length: > 0 })
{
return clrAssemblyName;
}
var separatorIndex = atsId.IndexOf('/');
return separatorIndex > 0 ? atsId[..separatorIndex] : string.Empty;
}
/// <summary>
/// Resolves the assembly that owns a capability, preferring CLR reflection info over the
/// identifier prefix so that instance members — whose IDs are namespace-qualified rather than
/// assembly-qualified — are attributed to the package that actually declares them.
/// </summary>
/// <remarks>
/// This mirrors <c>AtsContextFilter.IsCapabilityOwnedBySelectedAssembly</c>. The two must agree,
/// or the exporter would document symbols the filter excluded, or drop symbols it kept.
/// </remarks>
private string GetCapabilityOwningAssemblyName(AtsCapabilityInfo capability)
=> GetCapabilityOwningAssemblyName(_resolved.Context, capability);
/// <inheritdoc cref="GetCapabilityOwningAssemblyName(AtsCapabilityInfo)"/>
/// <remarks>
/// Takes the context explicitly so <see cref="Resolve"/> can attribute capabilities while it is
/// still building the model that <c>_resolved</c> will hold.
/// </remarks>
private static string GetCapabilityOwningAssemblyName(AtsContext context, AtsCapabilityInfo capability)
{
if (context.Methods.TryGetValue(capability.CapabilityId, out var method))
{
return method.DeclaringType?.Assembly.GetName().Name ?? string.Empty;
}
if (context.Properties.TryGetValue(capability.CapabilityId, out var property))
{
return property.DeclaringType?.Assembly.GetName().Name ?? string.Empty;
}
return GetOwningAssemblyName(capability.CapabilityId, capability.TargetType?.ClrType?.Assembly.GetName().Name);
}
/// <summary>
/// Resolves the assembly that owns a handle type, preferring CLR reflection info for the same
/// reason as <see cref="GetCapabilityOwningAssemblyName(AtsCapabilityInfo)"/>.
/// </summary>
private string GetTypeOwningAssemblyName(string typeId)
=> GetOwningAssemblyName(typeId, _typeRefsById.GetValueOrDefault(typeId)?.ClrType?.Assembly.GetName().Name);
// Mapping of typeId -> wrapper class name for all generated wrapper types
// Used to resolve parameter types to wrapper classes instead of handle types
private readonly Dictionary<string, string> _wrapperClassNames = new(StringComparer.Ordinal);
// Wrapper classes are deduplicated by generated class name, but their handles are branded by
// TypeId. Keep the retained TypeId so every canonical implementation receives its branded handle.
private readonly Dictionary<string, string> _concreteTypeIds = new(StringComparer.Ordinal);
private readonly Dictionary<string, AtsTypeRef> _typeRefsById = new(StringComparer.Ordinal);
// Set of type IDs that have Promise wrappers (chainable or directly returned resource builders)
// Used to determine return types for methods
private readonly HashSet<string> _typesWithPromiseWrappers = new(StringComparer.Ordinal);
// Set of generated options interfaces to avoid duplicates
private readonly HashSet<string> _generatedOptionsInterfaces = new(StringComparer.Ordinal);
// Collected options interfaces to generate (interface name -> list of optional params)
private readonly Dictionary<string, List<AtsParameterInfo>> _optionsInterfacesToGenerate = new(StringComparer.Ordinal);
// Mapping from CapabilityId to the options interface name it should use.
// When methods share a name but have incompatible callback parameter types,
// separate options interfaces are generated with numeric suffixes.
private readonly Dictionary<string, string> _capabilityOptionsInterfaceMap = new(StringComparer.Ordinal);
// Mapping from options interface name to the assembly that owns it. An interface belongs to the
// assembly whose capability produced it, which is not necessarily the package an export was
// requested for: a scan holds several assemblies, and only some of them are being documented.
private readonly Dictionary<string, string> _optionsInterfaceOwningAssemblies = new(StringComparer.Ordinal);
// Mapping of enum type IDs to TypeScript enum names
private readonly Dictionary<string, string> _enumTypeNames = new(StringComparer.Ordinal);
// Mapping of handle type IDs to XML documentation captured during ATS scanning.
private readonly Dictionary<string, AtsDocumentationInfo> _handleDocumentationById = new(StringComparer.Ordinal);
// Mapping of DTO type IDs to DTO metadata for generated argument marshalling.
private readonly Dictionary<string, AtsDtoTypeInfo> _dtoTypesById = new(StringComparer.Ordinal);
internal static string GetInterfaceName(string className) => className;
internal static string GetPromiseInterfaceName(string className) => $"{className}Promise";
internal static string GetImplementationClassName(string className) => $"{className}Impl";
internal static string GetImplementationPromiseClassName(string className) => $"{className}PromiseImpl";
internal static string GetReferenceExpressionInterfaceName() => "ReferenceExpression";
internal static string GetCancellationTokenInterfaceName() => "CancellationToken";
internal static string GetHandleReferenceInterfaceName() => "HandleReference";
internal static string GetInputTypeEnumName() => "InputType";
internal static string GetInteractionInputInterfaceName() => "InteractionInput";
internal static string GetInteractionInputCollectionClassName() => "InteractionInputCollection";
internal const string InputTypeTypeId = "enum:Aspire.Hosting.InputType";
internal const string InteractionInputTypeId = "Aspire.Hosting/Aspire.Hosting.InteractionInput";
internal const string InteractionInputCollectionTypeId = "Aspire.Hosting/Aspire.Hosting.InteractionInputCollection";
internal string GetConcreteClassName(string typeId) => _wrapperClassNames.GetValueOrDefault(typeId)
?? DeriveClassName(typeId);
internal string GetConcreteTypeId(string typeId) => _concreteTypeIds.GetValueOrDefault(typeId)
?? typeId;
internal string GetConcreteHandleTypeName(string typeId) => GetHandleTypeName(GetConcreteTypeId(typeId));
internal string GetPublicPromiseInterfaceName(string typeId) => GetPromiseInterfaceName(GetConcreteClassName(typeId));
internal static bool IsHandleType(AtsTypeRef? typeRef) =>
typeRef is { Category: AtsTypeCategory.Handle };
/// <summary>
/// Maps an AtsTypeRef to a TypeScript type using category-based dispatch.
/// This is the preferred method - uses type metadata rather than string parsing.
/// </summary>
internal string MapTypeRefToTypeScript(AtsTypeRef? typeRef)
{
if (typeRef is null)
{
return "unknown";
}
// ReferenceExpression is a value type defined in base.mts, not a handle-based wrapper
if (typeRef.TypeId == AtsConstants.ReferenceExpressionTypeId)
{
return GetReferenceExpressionInterfaceName();
}
if (typeRef.TypeId == InputTypeTypeId)
{
return GetInputTypeEnumName();
}
if (typeRef.TypeId == InteractionInputTypeId)
{
return GetInteractionInputInterfaceName();
}
if (typeRef.TypeId == InteractionInputCollectionTypeId)
{
return GetInteractionInputCollectionClassName();
}
// Check for wrapper class first (handles custom types like resource builders)
if (_wrapperClassNames.TryGetValue(typeRef.TypeId, out var wrapperClassName))
{
return GetInterfaceName(wrapperClassName);
}
var mappedType = typeRef.Category switch
{
AtsTypeCategory.Primitive => MapPrimitiveType(typeRef.TypeId),
AtsTypeCategory.Enum => MapEnumType(typeRef.TypeId),
AtsTypeCategory.Handle => GetWrapperOrHandleName(typeRef.TypeId),
AtsTypeCategory.Dto => GetDtoInterfaceName(typeRef.TypeId),
AtsTypeCategory.Callback => "Function", // Callbacks handled separately with full signature
AtsTypeCategory.Array => $"{MapTypeRefToTypeScript(typeRef.ElementType)}[]",
AtsTypeCategory.List => $"AspireList<{MapTypeRefToTypeScript(typeRef.ElementType)}>",
AtsTypeCategory.Dict => typeRef.IsReadOnly
? $"Record<{MapTypeRefToTypeScript(typeRef.KeyType)}, {MapTypeRefToTypeScript(typeRef.ValueType)}>"
: $"AspireDict<{MapTypeRefToTypeScript(typeRef.KeyType)}, {MapTypeRefToTypeScript(typeRef.ValueType)}>",
AtsTypeCategory.Union => MapUnionTypeToTypeScript(typeRef),
AtsTypeCategory.Unknown => "any", // Unknown types use 'any' since they're not in the ATS universe
_ => "any" // Fallback for any unhandled categories
};
return ApplyNullableType(typeRef, mappedType);
}
internal static string ApplyNullableType(AtsTypeRef typeRef, string mappedType)
{
if (typeRef.IsNullable != true || typeRef.Category is not (AtsTypeCategory.Primitive or AtsTypeCategory.Enum))
{
return mappedType;
}
return typeRef.TypeId is AtsConstants.Void or AtsConstants.Any or AtsConstants.CancellationToken
? mappedType
: $"{mappedType} | null";
}
internal string MapDtoPropertyTypeToTypeScript(AtsTypeRef? typeRef)
{
if (typeRef is null)
{
return "unknown";
}
return typeRef.Category switch
{
AtsTypeCategory.Array or AtsTypeCategory.List => $"{MapDtoPropertyTypeToTypeScript(typeRef.ElementType)}[]",
AtsTypeCategory.Dict => $"Record<{MapDtoPropertyTypeToTypeScript(typeRef.KeyType)}, {MapDtoPropertyTypeToTypeScript(typeRef.ValueType)}>",
AtsTypeCategory.Union => MapDtoUnionTypeToTypeScript(typeRef),
_ => MapTypeRefToTypeScript(typeRef)
};
}
internal string MapDtoUnionTypeToTypeScript(AtsTypeRef typeRef)
{
if (typeRef.UnionTypes is null || typeRef.UnionTypes.Count == 0)
{
return "unknown";
}
var memberTypes = typeRef.UnionTypes
.Select(MapDtoPropertyTypeToTypeScript)
.Distinct();
return string.Join(" | ", memberTypes);
}
/// <summary>
/// Maps primitive type IDs to TypeScript types.
/// </summary>
internal static string MapPrimitiveType(string typeId) => typeId switch
{
AtsConstants.String or AtsConstants.Char => "string",
AtsConstants.Number => "number",
AtsConstants.Boolean => "boolean",
AtsConstants.Void => "void",
AtsConstants.Any => "any",
AtsConstants.DateTime or AtsConstants.DateTimeOffset or
AtsConstants.DateOnly or AtsConstants.TimeOnly => "string",
AtsConstants.TimeSpan => "number",
AtsConstants.Guid or AtsConstants.Uri => "string",
AtsConstants.CancellationToken => GetCancellationTokenInterfaceName(),
_ => typeId
};
/// <summary>
/// Maps an enum type ID to the generated TypeScript enum name.
/// Throws if the enum type wasn't collected during scanning.
/// </summary>
internal string MapEnumType(string typeId)
{
if (!_enumTypeNames.TryGetValue(typeId, out var enumName))
{
throw new InvalidOperationException(
$"Enum type '{typeId}' was not found in the scanned enum types. " +
$"This indicates the enum type was not discovered during assembly scanning.");
}
return enumName;
}
/// <summary>
/// Maps a union type to TypeScript union syntax (T1 | T2 | ...).
/// </summary>
internal string MapUnionTypeToTypeScript(AtsTypeRef typeRef)
{
if (typeRef.UnionTypes == null || typeRef.UnionTypes.Count == 0)
{
return "unknown";
}
var memberTypes = typeRef.UnionTypes
.Select(MapTypeRefToTypeScript)
.Distinct();
return string.Join(" | ", memberTypes);
}
/// <summary>
/// Gets the wrapper class name or handle type name for a handle type ID.
/// Prefers wrapper class if one exists, otherwise generates a handle type name.
/// </summary>
internal string GetWrapperOrHandleName(string typeId)
{
if (_wrapperClassNames.TryGetValue(typeId, out var wrapperClassName))
{
return wrapperClassName;
}
return GetHandleTypeName(typeId);
}
/// <summary>
/// Gets a TypeScript interface name for a DTO type.
/// </summary>
internal static string GetDtoInterfaceName(string typeId)
{
return ExtractSimpleTypeName(typeId);
}
/// <summary>
/// Maps a user-supplied input type to TypeScript.
/// For interface handle types, generated APIs accept any handle-bearing wrapper instance.
/// For cancellation tokens, generated APIs accept either an AbortSignal or a transport-safe CancellationToken.
/// </summary>
/// <remarks>
/// Handle types are widened to accept <c>Awaitable<T></c> so callers can pass un-awaited
/// fluent chains directly. Examples:
/// <code>
/// // Input: RedisResource handle type
/// // Output: "Awaitable<RedisResource>"
///
/// // Input: Union of string | RedisResource
/// // Output: "string | Awaitable<RedisResource>"
///
/// // Input: CancellationToken type
/// // Output: "AbortSignal | CancellationToken"
///
/// // Input: plain string type
/// // Output: "string"
/// </code>
/// </remarks>
internal string MapInputTypeToTypeScript(AtsTypeRef? typeRef)
{
if (typeRef?.Category == AtsTypeCategory.Union)
{
return MapInputUnionTypeToTypeScript(typeRef);
}
if (IsInterfaceHandleType(typeRef))
{
if (TryMapInterfaceInputTypeToTypeScript(typeRef!) is { } interfaceInputType)
{
return $"Awaitable<{interfaceInputType}>";
}
var handleName = GetHandleReferenceInterfaceName();
return $"Awaitable<{handleName}>";
}
if (IsHandleType(typeRef) && _wrapperClassNames.TryGetValue(typeRef!.TypeId, out var className))
{
var ifaceName = GetInterfaceName(className);
return $"Awaitable<{ifaceName}>";
}
if (typeRef?.TypeId == InteractionInputCollectionTypeId)
{
return $"Awaitable<{GetInteractionInputCollectionClassName()}>";
}
if (IsCancellationTokenType(typeRef))
{
return $"AbortSignal | {GetCancellationTokenInterfaceName()}";
}
return MapTypeRefToTypeScript(typeRef);
}
internal string MapInputUnionTypeToTypeScript(AtsTypeRef typeRef)
{
if (typeRef.UnionTypes == null || typeRef.UnionTypes.Count == 0)
{
throw new InvalidOperationException("Union input types must define at least one member type.");
}
// Build union structurally: each member is mapped individually.
// Handle types become Awaitable<T>, non-handle types pass through as-is.
var nonHandleTypes = new List<string>();
var handleTypeNames = new List<string>();
foreach (var memberRef in typeRef.UnionTypes)
{
if (IsWidenedHandleType(memberRef))
{
// Get the base type name without Awaitable wrapper for combining
var baseName = IsInterfaceHandleType(memberRef) && TryMapInterfaceInputTypeToTypeScript(memberRef) is { } expanded
? expanded
: MapTypeRefToTypeScript(memberRef);
nonHandleTypes.Add(baseName);
handleTypeNames.Add(baseName);
}
else
{
nonHandleTypes.Add(MapInputTypeToTypeScript(memberRef));
}
}
var allBaseTypes = nonHandleTypes
.SelectMany(t => t.Split(" | ", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
.Distinct(StringComparer.Ordinal)
.ToList();
if (handleTypeNames.Count > 0)
{
var handleUnion = string.Join(" | ", handleTypeNames
.SelectMany(t => t.Split(" | ", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
.Distinct(StringComparer.Ordinal));
return string.Join(" | ", allBaseTypes) + $" | Awaitable<{handleUnion}>";
}
return string.Join(" | ", allBaseTypes);
}
/// <summary>
/// Maps a parameter to its TypeScript type, handling callbacks specially.
/// </summary>
internal string MapParameterToTypeScript(AtsParameterInfo param)
{
if (param.IsCallback)
{
return GenerateCallbackTypeSignature(param.CallbackParameters, param.CallbackReturnType);
}
return MapInputTypeToTypeScript(param.Type);
}
internal string? TryMapInterfaceInputTypeToTypeScript(AtsTypeRef typeRef)
{
List<string>? assignableWrapperTypes = null;
foreach (var candidateTypeRef in _typeRefsById.Values)
{
if (!IsAssignableToInterface(candidateTypeRef, typeRef.TypeId) ||
!_wrapperClassNames.TryGetValue(candidateTypeRef.TypeId, out var wrapperClassName))
{
continue;
}
assignableWrapperTypes ??= [];
assignableWrapperTypes.Add(wrapperClassName);
}
if (assignableWrapperTypes is not { Count: > 0 })
{
return null;
}
return string.Join(" | ", assignableWrapperTypes
.Distinct(StringComparer.Ordinal)
.OrderBy(static n => n, StringComparer.Ordinal));
}
internal static bool IsAssignableToInterface(AtsTypeRef candidateTypeRef, string interfaceTypeId)
{
if (string.Equals(candidateTypeRef.TypeId, interfaceTypeId, StringComparison.Ordinal))
{
return true;
}
foreach (var implementedInterface in candidateTypeRef.ImplementedInterfaces)
{
if (IsAssignableToInterface(implementedInterface, interfaceTypeId))
{
return true;
}
}
return candidateTypeRef.BaseType is not null && IsAssignableToInterface(candidateTypeRef.BaseType, interfaceTypeId);
}
/// <summary>
/// Checks if a type reference is an interface handle type.
/// Interface handles need union types to accept wrapper classes.
/// </summary>
internal static bool IsInterfaceHandleType(AtsTypeRef? typeRef)
{
if (typeRef == null)
{
return false;
}
return typeRef.Category == AtsTypeCategory.Handle && typeRef.IsInterface;
}
internal static bool IsCancellationTokenType(AtsTypeRef? typeRef) => typeRef?.TypeId == AtsConstants.CancellationToken;
/// <summary>
/// Gets a valid TypeScript method name from a capability method name.
/// Handles dotted names like "EnvironmentContext.resource" by extracting just the final part.
/// </summary>
internal static string GetTypeScriptMethodName(string methodName)
{
var dotIndex = methodName.LastIndexOf('.');
return dotIndex >= 0 ? methodName[(dotIndex + 1)..] : methodName;
}
/// <summary>
/// Converts a PascalCase name to camelCase.
/// </summary>
internal static string ToCamelCase(string name)
{
if (string.IsNullOrEmpty(name))
{
return name;
}
if (char.IsLower(name[0]))
{
return name;
}
return char.ToLowerInvariant(name[0]) + name[1..];
}
/// <summary>
/// Converts a camelCase name to PascalCase.
/// </summary>
internal static string ToPascalCase(string name)
{
if (string.IsNullOrEmpty(name))
{
return name;
}
if (char.IsUpper(name[0]))
{
return name;
}
return char.ToUpperInvariant(name[0]) + name[1..];
}
/// <summary>
/// Gets the options interface name for a method.
/// Strips any type prefix (e.g., "TypeName.methodName" -> "MethodName").
/// </summary>
internal static string GetOptionsInterfaceName(string methodName)
{
var simpleName = methodName.Contains('.')
? methodName[(methodName.LastIndexOf('.') + 1)..]
: methodName;
return $"{ToPascalCase(simpleName)}Options";
}
/// <summary>
/// Gets the options interface name for a specific capability, accounting for type conflicts.
/// Falls back to the default name derived from the capability if no specific mapping exists.
/// </summary>
internal string ResolveOptionsInterfaceName(AtsCapabilityInfo capability)
{
if (_capabilityOptionsInterfaceMap.TryGetValue(capability.CapabilityId, out var interfaceName))
{
return interfaceName;
}
return GetOptionsInterfaceName(capability.MethodName);
}
/// <summary>
/// Separates parameters into required and optional lists.
/// Required = not optional and not nullable.
/// </summary>
internal static (List<AtsParameterInfo> Required, List<AtsParameterInfo> Optional) SeparateParameters(
IEnumerable<AtsParameterInfo> parameters)
{
var required = new List<AtsParameterInfo>();
var optional = new List<AtsParameterInfo>();
foreach (var param in parameters)
{
if (param.IsOptional || param.IsNullable)
{
optional.Add(param);
}
else
{
required.Add(param);
}
}
return (required, optional);
}
internal static bool TryGetDirectOptionsParameter(List<AtsParameterInfo> optionalParams, out AtsParameterInfo? directOptionsParam)
// A trailing cancellation token is rendered as its own parameter (see
// GetTrailingCancellationTokenParameter), so it is ignored when deciding whether the lone
// "options" DTO can be threaded directly instead of wrapped in a generated options object.
=> AtsOptionsFlattening.TryGetDirectOptionsParameter(
optionalParams,
p => IsCancellationTokenType(p.Type),
cancellationTokenIsSeparateParameter: true,
out directOptionsParam);
/// <summary>
/// When the options DTO is threaded directly (see <see cref="TryGetDirectOptionsParameter"/>),
/// returns the trailing cancellation token optional parameter (if any) so it can be appended to
/// the generated method as its own argument rather than being folded into a generated options bag.
/// </summary>
internal static AtsParameterInfo? GetTrailingCancellationTokenParameter(List<AtsParameterInfo> optionalParams)
{
if (!TryGetDirectOptionsParameter(optionalParams, out _))
{
return null;
}
return optionalParams.FirstOrDefault(p => IsCancellationTokenType(p.Type));
}
/// <summary>
/// Registers an options interface to be generated later.
/// </summary>
/// <param name="capabilityId">The capability the interface is being registered for.</param>
/// <param name="methodName">The method name the interface is derived from.</param>
/// <param name="optionalParams">The optional parameters the interface carries.</param>
/// <param name="owningAssemblyName">The assembly that exports <paramref name="capabilityId"/>.</param>
internal void RegisterOptionsInterface(
string capabilityId,
string methodName,
List<AtsParameterInfo> optionalParams,
string owningAssemblyName)
{
if (optionalParams.Count == 0)
{
return;
}
var baseInterfaceName = GetOptionsInterfaceName(methodName);
// Check if an existing interface with this name is compatible
if (_optionsInterfacesToGenerate.TryGetValue(baseInterfaceName, out var existingParams))
{
if (AreOptionsCompatible(existingParams, optionalParams))
{
// Compatible - merge any new parameters and share the interface
AssignOptionsInterface(capabilityId, baseInterfaceName, optionalParams, owningAssemblyName);
return;
}
// Incompatible - find or create a suffixed interface.
for (var suffix = 1; ; suffix++)
{
var suffixedName = GetOptionsInterfaceName($"{methodName}{suffix}");
if (!_optionsInterfacesToGenerate.TryGetValue(suffixedName, out var suffixedParams))
{
// Create a new interface with this suffix
AssignOptionsInterface(capabilityId, suffixedName, optionalParams, owningAssemblyName);
return;
}
if (AreOptionsCompatible(suffixedParams, optionalParams))
{
// Compatible with this suffixed interface - share it
AssignOptionsInterface(capabilityId, suffixedName, optionalParams, owningAssemblyName);
return;
}
}
}
else
{
// First registration - create the interface
AssignOptionsInterface(capabilityId, baseInterfaceName, optionalParams, owningAssemblyName);
}
}
/// <summary>
/// Points a capability at a named options interface, creating the interface if this is its first
/// use and otherwise widening it with any parameters it does not already carry.
/// </summary>
private void AssignOptionsInterface(
string capabilityId,
string interfaceName,
List<AtsParameterInfo> optionalParams,
string owningAssemblyName)
{
if (_optionsInterfacesToGenerate.TryGetValue(interfaceName, out var declaredParams))
{
foreach (var param in optionalParams)
{
var declaredIndex = declaredParams.FindIndex(
declared => string.Equals(declared.Name, param.Name, StringComparison.Ordinal));
if (declaredIndex < 0)
{
declaredParams.Add(param);
}
else if (declaredParams[declaredIndex].Documentation is null && param.Documentation is not null)
{
// Compatible overloads can contribute the same option with different metadata.
// Keep the documented form regardless of which capability has the lower stable ID.
declaredParams[declaredIndex] = param;
}
}
}
else
{
_generatedOptionsInterfaces.Add(interfaceName);
_optionsInterfacesToGenerate[interfaceName] = [.. optionalParams];
}
_capabilityOptionsInterfaceMap[capabilityId] = interfaceName;
_optionsInterfaceOwningAssemblies[interfaceName] = owningAssemblyName;
}
/// <summary>
/// Checks whether two sets of optional parameters are compatible for sharing an options interface.
/// Parameters with the same name must have the same type (including callback parameter types).
/// </summary>
internal static bool AreOptionsCompatible(List<AtsParameterInfo> existing, List<AtsParameterInfo> candidate)
{
foreach (var param in candidate)
{
var match = existing.FirstOrDefault(p => p.Name == param.Name);
if (match is null)
{
continue; // New parameter, no conflict
}
// Same name - check type compatibility
if (!AreParameterTypesEqual(match, param))
{
return false;
}
}
return true;
}
/// <summary>
/// Checks whether two parameter infos have the same type (including callback types).
/// </summary>
internal static bool AreParameterTypesEqual(AtsParameterInfo a, AtsParameterInfo b)
{
// Compare base type
var aTypeId = a.Type?.TypeId;
var bTypeId = b.Type?.TypeId;
if (!string.Equals(aTypeId, bTypeId, StringComparison.Ordinal))
{
return false;
}
// Compare callback parameter types
if (a.IsCallback != b.IsCallback)
{
return false;
}
if (a.IsCallback && b.IsCallback)
{
var aCallbackParams = a.CallbackParameters ?? [];
var bCallbackParams = b.CallbackParameters ?? [];
if (aCallbackParams.Count != bCallbackParams.Count)
{
return false;
}
for (var i = 0; i < aCallbackParams.Count; i++)
{
if (!string.Equals(aCallbackParams[i].Type.TypeId, bCallbackParams[i].Type.TypeId, StringComparison.Ordinal))
{
return false;
}
}
// Compare callback return types
var aReturnTypeId = a.CallbackReturnType?.TypeId;
var bReturnTypeId = b.CallbackReturnType?.TypeId;
if (!string.Equals(aReturnTypeId, bReturnTypeId, StringComparison.Ordinal))
{
return false;
}
}
return true;
}
internal static string GetTypeDescription(string typeId)
{
var typeName = ExtractSimpleTypeName(typeId);
return $"Handle to {typeName}";
}
internal string BuildPublicParameterList(
List<AtsParameterInfo> requiredParams,
bool hasOptionals,
string optionsInterfaceName,
string optionsParameterName = "options",
AtsParameterInfo? trailingCancellationToken = null)
{
var publicParamDefs = new List<string>();
foreach (var param in requiredParams)
{
var tsType = MapParameterToTypeScript(param);
publicParamDefs.Add($"{param.Name}: {tsType}");
}
if (hasOptionals)
{
publicParamDefs.Add($"{optionsParameterName}?: {optionsInterfaceName}");
}
if (trailingCancellationToken is not null)
{
publicParamDefs.Add($"{trailingCancellationToken.Name}?: {MapParameterToTypeScript(trailingCancellationToken)}");
}
return string.Join(", ", publicParamDefs);
}
internal static string GetPublicOptionsParameterName(
IReadOnlyList<AtsParameterInfo> userParams,
bool hasOptionals,
bool hasDirectOptionsParameter)
{
if (!hasOptionals || hasDirectOptionsParameter)
{
return "options";
}
var (requiredParams, optionalParams) = SeparateParameters(userParams);
var trailingCancellationToken = GetTrailingCancellationTokenParameter(optionalParams);
bool IsPublicParameterName(string name)
=> requiredParams.Any(p => string.Equals(p.Name, name, StringComparison.Ordinal))
|| string.Equals(trailingCancellationToken?.Name, name, StringComparison.Ordinal);
if (!IsPublicParameterName("options"))
{
return "options";
}
var candidate = "optionsBag";
while (IsPublicParameterName(candidate))
{
candidate = $"_{candidate}";
}
return candidate;
}
internal static string GetImplementationOptionsParameterName(
IReadOnlyList<AtsParameterInfo> userParams,
bool hasOptionals,
bool hasDirectOptionsParameter)
{
if (!hasOptionals || hasDirectOptionsParameter)
{
return "options";
}
// Implementation methods destructure every optional field into a local with its source
// parameter name. Unlike the public interface, their options-bag parameter must therefore
// avoid optional names too (for example: const options = optionsBag?.options).
if (!userParams.Any(p => string.Equals(p.Name, "options", StringComparison.Ordinal)))
{
return "options";
}
var candidate = "optionsBag";
while (userParams.Any(p => string.Equals(p.Name, candidate, StringComparison.Ordinal)))
{
candidate = $"_{candidate}";
}
return candidate;
}
internal static bool IsGetterOnlyProperty(AtsCapabilityInfo? getter, AtsCapabilityInfo? setter) => getter is not null && setter is null;
internal string GetGetterOnlyPropertyReturnType(AtsTypeRef? typeRef)
{
if (typeRef == null)
{
return "unknown";
}
if (IsDictionaryType(typeRef))
{
var keyType = typeRef.KeyType != null ? MapTypeRefToTypeScript(typeRef.KeyType) : "string";
var valueType = typeRef.ValueType != null ? MapTypeRefToTypeScript(typeRef.ValueType) : "unknown";
return $"AspireDict<{keyType}, {valueType}>";
}
if (IsListType(typeRef))
{
var elementType = typeRef.ElementType != null ? MapTypeRefToTypeScript(typeRef.ElementType) : "unknown";
return $"AspireList<{elementType}>";
}
return MapTypeRefToTypeScript(typeRef);
}
internal bool TryGetPromiseWrapperType(AtsTypeRef? typeRef, out string promiseInterfaceName, out string promiseImplementationClassName)
{
if (typeRef?.TypeId is { } typeId && _typesWithPromiseWrappers.Contains(typeId))
{
var className = GetConcreteClassName(typeId);
promiseInterfaceName = GetPromiseInterfaceName(className);
promiseImplementationClassName = GetImplementationPromiseClassName(className);
return true;
}
promiseInterfaceName = string.Empty;
promiseImplementationClassName = string.Empty;
return false;
}
internal string GetGetterOnlyPropertyMethodReturnType(AtsTypeRef? typeRef)
{
if (TryGetPromiseWrapperType(typeRef, out var promiseInterfaceName, out _))
{
return promiseInterfaceName;
}
return $"Promise<{GetGetterOnlyPropertyReturnType(typeRef)}>";
}
internal string GetBuilderPromiseInterfaceForMethod(BuilderModel builder, AtsCapabilityInfo capability)
{
if (capability.ReturnsBuilder && capability.ReturnType?.TypeId != null &&
!string.Equals(capability.ReturnType.TypeId, builder.TypeId, StringComparison.Ordinal) &&
!string.Equals(capability.ReturnType.TypeId, capability.TargetTypeId, StringComparison.Ordinal))
{
return GetPublicPromiseInterfaceName(capability.ReturnType.TypeId);
}
return GetPromiseInterfaceName(builder.BuilderClassName);
}
/// <summary>
/// Checks if a type was widened to accept Awaitable<T> in input position.
/// Must match the widening logic in MapInputTypeToTypeScript exactly.
/// </summary>
internal bool IsWidenedHandleType(AtsTypeRef? typeRef)
{
if (typeRef == null)
{
return false;
}
// Interface handles are always widened
if (IsInterfaceHandleType(typeRef))
{
return true;
}
// Concrete handles are only widened if they have a wrapper class name
// (excludes special types like ReferenceExpression that bypass widening)
if (IsHandleType(typeRef) && _wrapperClassNames.ContainsKey(typeRef.TypeId))
{
return true;
}
if (typeRef.TypeId == InteractionInputCollectionTypeId)
{
return true;
}
if (typeRef.Category == AtsTypeCategory.Union && typeRef.UnionTypes is { Count: > 0 })
{
return typeRef.UnionTypes.Any(IsWidenedHandleType);
}
return false;
}
/// <summary>
/// Groups getters and setters by property name.
/// </summary>
internal static List<(string PropertyName, AtsCapabilityInfo? Getter, AtsCapabilityInfo? Setter)> GroupPropertiesByName(
List<AtsCapabilityInfo> getters, List<AtsCapabilityInfo> setters)
{
var result = new List<(string PropertyName, AtsCapabilityInfo? Getter, AtsCapabilityInfo? Setter)>();
var processedNames = new HashSet<string>();
// Process getters
foreach (var getter in getters)
{
var propName = ExtractPropertyName(getter.MethodName);
if (processedNames.Contains(propName))
{
continue;
}
processedNames.Add(propName);
// Find matching setter (setPropertyName for propertyName)
var setterName = "set" + char.ToUpperInvariant(propName[0]) + propName[1..];
var setter = setters.FirstOrDefault(s => ExtractPropertyName(s.MethodName).Equals(setterName, StringComparison.OrdinalIgnoreCase));
result.Add((propName, getter, setter));
}
// Process any setters without matching getters
foreach (var setter in setters)
{
var setterMethodName = ExtractPropertyName(setter.MethodName);
// setPropertyName -> propertyName
if (setterMethodName.StartsWith("set", StringComparison.OrdinalIgnoreCase) && setterMethodName.Length > 3)
{
var propName = char.ToLowerInvariant(setterMethodName[3]) + setterMethodName[4..];
if (!processedNames.Contains(propName))
{
processedNames.Add(propName);
result.Add((propName, null, setter));
}
}
}
return result;
}
/// <summary>
/// Extracts the property name from a method name like "ClassName.propertyName" or "setPropertyName".
/// </summary>
internal static string ExtractPropertyName(string methodName)
{
// Handle "ClassName.propertyName" format
if (methodName.Contains('.'))
{
return methodName[(methodName.LastIndexOf('.') + 1)..];
}
return methodName;
}
/// <summary>
/// Checks if a type reference is a dictionary type.
/// </summary>
internal static bool IsDictionaryType(AtsTypeRef? typeRef)
{
return typeRef?.Category == AtsTypeCategory.Dict;
}
/// <summary>
/// Checks if a type reference is a list type.
/// </summary>
internal static bool IsListType(AtsTypeRef? typeRef)
{
return typeRef?.Category == AtsTypeCategory.List;
}
/// <summary>
/// Groups capabilities by ExpandedTargetTypes to create builder models.
/// Uses expansion to map interface targets to their concrete implementations.
/// Also creates builders for interface types (for use as return type wrappers).
/// </summary>
internal static List<BuilderModel> CreateBuilderModels(IReadOnlyList<AtsCapabilityInfo> capabilities)
{
// Group capabilities by expanded target type IDs
// A capability targeting IResource with ExpandedTargetTypes = [RedisResource]
// will be assigned to Aspire.Hosting.Redis/RedisResource (the concrete type)
var capabilitiesByTypeId = new Dictionary<string, List<AtsCapabilityInfo>>();
// Track the AtsTypeRef for each typeId (from ExpandedTargetTypes or TargetType metadata)
var typeRefsByTypeId = new Dictionary<string, AtsTypeRef>();
// Also track interface types and their capabilities (for interface wrapper classes)
var interfaceCapabilities = new Dictionary<string, List<AtsCapabilityInfo>>();
foreach (var cap in capabilities)
{
var targetTypeRef = cap.TargetType;
var targetTypeId = cap.TargetTypeId;
if (targetTypeRef == null || string.IsNullOrEmpty(targetTypeId))
{
// Entry point methods - handled separately
continue;
}
// Use category-based check instead of string parsing
if (targetTypeRef.Category != AtsTypeCategory.Handle)
{
continue;
}
// These types are implemented manually in base.mts, including handle wrapper
// registrations, so they must not also generate duplicate wrappers in aspire.mts.
if (targetTypeId is AtsConstants.ReferenceExpressionTypeId or InteractionInputCollectionTypeId)
{
continue;
}
// Use expanded types if available, otherwise fall back to the original target
var expandedTypes = cap.ExpandedTargetTypes;
if (expandedTypes is { Count: > 0 })
{
// Flatten to concrete types
foreach (var expandedType in expandedTypes)
{
if (!capabilitiesByTypeId.TryGetValue(expandedType.TypeId, out var list))
{
list = [];
capabilitiesByTypeId[expandedType.TypeId] = list;
// Store the type ref for this expanded type
typeRefsByTypeId[expandedType.TypeId] = expandedType;
}
list.Add(cap);
}
// Also track the original interface type for wrapper class generation
if (targetTypeRef.IsInterface)
{
if (!interfaceCapabilities.TryGetValue(targetTypeId, out var interfaceList))
{
interfaceList = [];
interfaceCapabilities[targetTypeId] = interfaceList;
// Store the type ref for the interface
typeRefsByTypeId[targetTypeId] = targetTypeRef;
}
interfaceList.Add(cap);
}
}
else
{
// No expansion - use original target (concrete type)
if (!capabilitiesByTypeId.TryGetValue(targetTypeId, out var list))
{
list = [];
capabilitiesByTypeId[targetTypeId] = list;
// Store the type ref for this target type
typeRefsByTypeId[targetTypeId] = targetTypeRef;
}
list.Add(cap);
}
}
// Create a builder for each concrete type with its specific capabilities
var builders = new List<BuilderModel>();
foreach (var (typeId, typeCapabilities) in capabilitiesByTypeId)
{
var builderClassName = DeriveClassName(typeId);
// Get the type ref from tracked metadata (based on target type, not return type)
var typeRef = typeRefsByTypeId.GetValueOrDefault(typeId);
// Deduplicate capabilities by CapabilityId to avoid duplicate methods
var uniqueCapabilities = typeCapabilities
.GroupBy(c => c.CapabilityId)
.Select(g => g.First())
.ToList();
SortOptionsInterfaceCollisionsByCapabilityIdentity(uniqueCapabilities);
var builder = new BuilderModel
{
TypeId = typeId,
BuilderClassName = builderClassName,
Capabilities = uniqueCapabilities,
IsInterface = typeRef?.IsInterface ?? false,
TargetType = typeRef
};
builders.Add(builder);
}
// Also create builders for interface types (for use as return type wrappers)
// These are needed when methods return interface types like IResourceWithConnectionString
foreach (var (interfaceTypeId, caps) in interfaceCapabilities)
{
// Skip if already added (shouldn't happen, but be safe)
if (capabilitiesByTypeId.ContainsKey(interfaceTypeId))
{
continue;
}
var builderClassName = DeriveClassName(interfaceTypeId);
// Get the type ref from tracked metadata
var typeRef = typeRefsByTypeId.GetValueOrDefault(interfaceTypeId);
// Deduplicate capabilities
var uniqueCapabilities = caps
.GroupBy(c => c.CapabilityId)
.Select(g => g.First())
.ToList();
SortOptionsInterfaceCollisionsByCapabilityIdentity(uniqueCapabilities);
var builder = new BuilderModel
{
TypeId = interfaceTypeId,
BuilderClassName = builderClassName,
Capabilities = uniqueCapabilities,
IsInterface = true,
TargetType = typeRef
};
builders.Add(builder);
}
// Also create builders for resource types referenced anywhere in capabilities
// This handles types like RedisCommanderResource that appear in callback signatures,
// return types, or parameter types but aren't capability targets
var allReferencedTypeRefs = CollectAllReferencedTypes(capabilities);
// Track all types we already have builders for (concrete + interface)
var existingBuilderTypeIds = new HashSet<string>(capabilitiesByTypeId.Keys);
foreach (var (interfaceTypeId, _) in interfaceCapabilities)
{
existingBuilderTypeIds.Add(interfaceTypeId);
}
foreach (var (typeId, typeRef) in allReferencedTypeRefs)
{
// Skip types we already have builders for (from concrete or interface lists)
if (existingBuilderTypeIds.Contains(typeId))
{
continue;
}
// Only create builders for resource types (using metadata instead of string parsing)
if (!typeRef.IsResourceBuilder)
{
continue;
}
var builderClassName = DeriveClassName(typeId);
var builder = new BuilderModel
{
TypeId = typeId,
BuilderClassName = builderClassName,
Capabilities = [], // No specific capabilities - uses base type methods
IsInterface = typeRef.IsInterface,
TargetType = typeRef
};
builders.Add(builder);
}
// Deduplicate a concrete type and its interfaces by class name. Unrelated CLR types can have
// the same simple name, but treating them as aliases would bind one type's branded handle to
// the other's wrapper implementation.
return builders
.OrderBy(builder => builder.IsInterface)
.ThenBy(builder => builder.BuilderClassName)
.GroupBy(builder => builder.BuilderClassName, StringComparer.Ordinal)
.Select(group =>
{
var candidates = group
.OrderBy(builder => builder.IsInterface)
.ThenBy(builder => builder.TypeId, StringComparer.Ordinal)
.ToList();
var retainedBuilder = candidates[0];
var unrelatedBuilder = candidates
.Skip(1)
.FirstOrDefault(candidate => !IsBuilderAlias(retainedBuilder, candidate));
if (unrelatedBuilder is not null)
{
var collidingTypeIds = candidates
.Select(candidate => candidate.TypeId)
.Order(StringComparer.Ordinal);
throw new InvalidOperationException(
$"Resource types {string.Join(", ", collidingTypeIds.Select(typeId => $"'{typeId}'"))} " +
$"all map to the generated TypeScript name '{group.Key}', but they are not a concrete type and its interfaces.");
}
return retainedBuilder;
})
.ToList();
}
private static void SortOptionsInterfaceCollisionsByCapabilityIdentity(List<AtsCapabilityInfo> capabilities)
{
// Reorder only colliding option-interface slots. Sorting every capability would rewrite
// long-established source order for methods unrelated to the collision.
var collisionGroups = capabilities
.Select((capability, index) => (Capability: capability, Index: index))
.Where(entry =>
{
var (_, optionalParameters) = SeparateParameters(entry.Capability.Parameters);
return optionalParameters.Count > 0 &&
!TryGetDirectOptionsParameter(optionalParameters, out _);
})
.GroupBy(
entry => GetOptionsInterfaceName(entry.Capability.MethodName),
StringComparer.Ordinal)
.Where(group => group.Count() > 1);
foreach (var group in collisionGroups)
{
var indexes = group.Select(entry => entry.Index).Order().ToList();
var orderedCapabilities = group
.Select(entry => entry.Capability)
.OrderBy(capability => capability.CapabilityId, StringComparer.Ordinal)
.ToList();
for (var i = 0; i < indexes.Count; i++)
{
capabilities[indexes[i]] = orderedCapabilities[i];
}
}
}
private static bool IsBuilderAlias(BuilderModel retainedBuilder, BuilderModel candidate)
{
if (string.Equals(retainedBuilder.TypeId, candidate.TypeId, StringComparison.Ordinal))
{
return true;
}
if (retainedBuilder.IsInterface == candidate.IsInterface ||
retainedBuilder.TargetType is not { } retainedType ||
candidate.TargetType is not { } candidateType)
{
return false;
}
if (retainedType.ClrType is { } retainedClrType && candidateType.ClrType is { } candidateClrType)
{
return retainedClrType.IsAssignableFrom(candidateClrType) ||
candidateClrType.IsAssignableFrom(retainedClrType);
}
return IsTypeInHierarchy(retainedType, candidateType.TypeId) ||
IsTypeInHierarchy(candidateType, retainedType.TypeId);
}
private static bool IsTypeInHierarchy(AtsTypeRef typeRef, string typeId)
{
if (typeRef.ImplementedInterfaces.Any(interfaceType =>
string.Equals(interfaceType.TypeId, typeId, StringComparison.Ordinal) ||
IsTypeInHierarchy(interfaceType, typeId)))
{
return true;
}
return typeRef.BaseType is { } baseType &&
(string.Equals(baseType.TypeId, typeId, StringComparison.Ordinal) ||
IsTypeInHierarchy(baseType, typeId));
}
/// <summary>
/// Collects all type refs referenced in capabilities (return types, parameter types, callback types, etc.)
/// Returns a dictionary mapping typeId to AtsTypeRef for use in builder creation.
/// </summary>
internal static Dictionary<string, AtsTypeRef> CollectAllReferencedTypes(IReadOnlyList<AtsCapabilityInfo> capabilities)
{
var typeRefs = new Dictionary<string, AtsTypeRef>();
void CollectFromTypeRef(AtsTypeRef? typeRef)
{
if (typeRef == null)
{
return;
}
if (!string.IsNullOrEmpty(typeRef.TypeId) && typeRef.Category == AtsTypeCategory.Handle)
{
typeRefs.TryAdd(typeRef.TypeId, typeRef);
}
// Also check nested types (generics, arrays, etc.)
CollectFromTypeRef(typeRef.ElementType);
CollectFromTypeRef(typeRef.KeyType);
CollectFromTypeRef(typeRef.ValueType);
if (typeRef.UnionTypes != null)
{
foreach (var unionType in typeRef.UnionTypes)
{
CollectFromTypeRef(unionType);
}
}
}
foreach (var cap in capabilities)
{
// Check return type
CollectFromTypeRef(cap.ReturnType);
// Check parameter types
foreach (var param in cap.Parameters)
{
CollectFromTypeRef(param.Type);
// Check callback parameter types and return type
if (param.IsCallback)
{
if (param.CallbackParameters != null)
{
foreach (var cbParam in param.CallbackParameters)
{
CollectFromTypeRef(cbParam.Type);
}
}
CollectFromTypeRef(param.CallbackReturnType);
}
}
}
return typeRefs;
}
/// <summary>
/// Gets entry point capabilities (those without TargetTypeId).
/// </summary>
internal static List<AtsCapabilityInfo> GetEntryPointCapabilities(IReadOnlyList<AtsCapabilityInfo> capabilities)
{
return capabilities.Where(c => string.IsNullOrEmpty(c.TargetTypeId)).ToList();
}
/// <summary>
/// Derives the class name from an ATS type ID.
/// For interfaces like IResource, strips the leading 'I'.
/// </summary>
internal static string DeriveClassName(string typeId)
{
var typeName = ExtractSimpleTypeName(typeId);
// Strip leading 'I' from interface types
if (typeName.StartsWith('I') && typeName.Length > 1 && char.IsUpper(typeName[1]))
{
return typeName[1..];
}
return typeName;
}
/// <summary>
/// Gets the handle type alias name for a type ID.
/// </summary>
internal static string GetHandleTypeName(string typeId)
{
var typeName = ExtractSimpleTypeName(typeId);
// Sanitize generic types like "Dict<String,Object>" -> "DictStringObject"
// and array types like "string[]" -> "stringArray"
typeName = typeName
.Replace("[]", "Array", StringComparison.Ordinal)
.Replace("<", "", StringComparison.Ordinal)
.Replace(">", "", StringComparison.Ordinal)
.Replace(",", "", StringComparison.Ordinal);
return $"{typeName}Handle";
}
/// <summary>
/// Extracts the simple type name from a type ID.
/// </summary>
/// <example>
/// "Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource" → "IResource"
/// "Aspire.Hosting/Aspire.Hosting.DistributedApplication" → "DistributedApplication"
/// </example>
internal static string ExtractSimpleTypeName(string typeId)
{
var slashIndex = typeId.LastIndexOf('/');
var fullTypeName = slashIndex >= 0 ? typeId[(slashIndex + 1)..] : typeId;
var dotIndex = fullTypeName.LastIndexOf('.');
return dotIndex >= 0 ? fullTypeName[(dotIndex + 1)..] : fullTypeName;
}
/// <summary>
/// Determines if a type has generated async members and should have a Promise wrapper.
/// Types with instance methods, wrapper methods, or getter-only properties get Promise wrappers.
/// </summary>
internal static bool HasChainableMethods(BuilderModel model)
{
var hasMethods = model.Capabilities.Any(c =>
c.CapabilityKind == AtsCapabilityKind.InstanceMethod ||
c.CapabilityKind == AtsCapabilityKind.Method);
if (hasMethods)
{
return true;
}
var getters = model.Capabilities.Where(c => c.CapabilityKind == AtsCapabilityKind.PropertyGetter).ToList();
var setters = model.Capabilities.Where(c => c.CapabilityKind == AtsCapabilityKind.PropertySetter).ToList();
return GroupPropertiesByName(getters, setters).Any(p => IsGetterOnlyProperty(p.Getter, p.Setter));
}
/// <summary>
/// Gets the Promise wrapper class name for a return type, if one exists.
/// Returns null if the return type doesn't have a Promise wrapper.
/// </summary>
internal string? GetPromiseWrapperForReturnType(AtsTypeRef? returnType)
{
if (returnType == null)
{
return null;
}
// Check if the return type has a Promise wrapper
if (_typesWithPromiseWrappers.Contains(returnType.TypeId))
{
var className = _wrapperClassNames.GetValueOrDefault(returnType.TypeId)
?? DeriveClassName(returnType.TypeId);
return $"{className}Promise";
}
return null;
}
internal string GenerateCallbackTypeSignature(IReadOnlyList<AtsCallbackParameterInfo>? callbackParameters, AtsTypeRef? callbackReturnType)
{
// Build parameter list
var paramList = new List<string>();
if (callbackParameters is not null)
{
foreach (var param in callbackParameters)
{
var tsType = MapTypeRefToTypeScript(param.Type);
paramList.Add($"{param.Name}: {tsType}");
}
}
var paramsString = paramList.Count > 0 ? string.Join(", ", paramList) : "";
// Determine return type
var returnType = callbackReturnType == null || callbackReturnType.TypeId == AtsConstants.Void
? "void"
: MapTypeRefToTypeScript(callbackReturnType);
// Callbacks are always async in TypeScript
return $"({paramsString}) => Promise<{returnType}>";
}
private sealed class ExportedValueTreeNode
{
public Dictionary<string, ExportedValueTreeNode> Children { get; } = new(StringComparer.Ordinal);
public AtsExportedValueInfo? Value { get; set; }
}
}
/// <summary>
/// A DTO property that exists only on the TypeScript side, with the type and summary both the module
/// emitter and the API export render.
/// </summary>
internal sealed record ClientOnlyDtoProperty(string Name, string Type, string Summary);