// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics;
using System.Globalization;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Shared.CodeGeneration;
using Aspire.Shared.Json;
using Aspire.TypeSystem;
namespace Aspire.Hosting.CodeGeneration.Go;
internal sealed class GoExportedValueTreeNode
{
public Dictionary<string, GoExportedValueTreeNode> Children { get; } = new(StringComparer.Ordinal);
public AtsExportedValueInfo? Value { get; set; }
}
/// <summary>
/// Generates a Go SDK using the ATS (Aspire Type System) capability-based API.
///
/// Architecture (mirrors the TypeScript generator):
/// - Public surface is interfaces; concrete impls are unexported structs.
/// - Add* / With* methods are non-blocking: they submit to the builder's
/// builderContext and return a lazy proxy immediately.
/// - Build() is the single flush point; it waits for all submitted goroutines
/// and aggregates errors via errors.Join.
/// - No init() functions; per-client wrapper / callback registries are
/// populated inside CreateBuilder via registerWrappers(c).
/// - Optional parameters use grpc-go-style functional options that apply to
/// generated options structs.
/// - Union parameters use Go 1.18+ type-set generics — no AspireUnion wrapper.
/// </summary>
internal sealed partial class AtsGoCodeGenerator : ICodeGenerator
{
private static readonly HashSet<string> s_goKeywords = new(StringComparer.Ordinal)
{
"break", "case", "chan", "const", "continue", "default", "defer", "else",
"fallthrough", "for", "func", "go", "goto", "if", "import", "interface",
"map", "package", "range", "return", "select", "struct", "switch", "type", "var",
"any", "true", "false", "nil", "iota",
};
// byte and rune are aliases of uint8 and int32; listing them too would be a duplicate case in a type switch.
private static readonly string[] s_goNumericTypes =
[
"int", "int8", "int16", "int32", "int64",
"uint", "uint8", "uint16", "uint32", "uint64",
"float32",
];
private TextWriter _writer = null!;
// typeId → exported Go interface name (e.g., "Aspire.Hosting/...IResource" → "Resource")
private readonly Dictionary<string, string> _interfaceNames = new(StringComparer.Ordinal);
// typeId → unexported Go struct name (concrete types only; e.g., "redisResource")
private readonly Dictionary<string, string> _implNames = new(StringComparer.Ordinal);
// typeId → bool: is this type an interface in metadata?
private readonly Dictionary<string, bool> _isInterfaceType = new(StringComparer.Ordinal);
// typeId → bool: is this concrete type a resource builder?
private readonly Dictionary<string, bool> _isResourceBuilder = new(StringComparer.Ordinal);
private readonly Dictionary<string, string> _dtoNames = new(StringComparer.Ordinal);
private readonly Dictionary<string, string> _enumNames = new(StringComparer.Ordinal);
// capabilityId → resolved Options type name (qualified when simple name collides with a DTO or another capability's Options)
private readonly Dictionary<string, string> _optionsTypeNames = new(StringComparer.Ordinal);
private readonly Dictionary<string, string> _optionInterfaceNames = new(StringComparer.Ordinal);
private readonly Dictionary<(string CapabilityId, string ParameterName), string> _optionValueNames = new();
private string? _createBuilderOptionInterfaceName;
private readonly Dictionary<string, string> _createBuilderOptionValueNames = new(StringComparer.Ordinal);
// typeId → documentation captured for the handle type
private readonly Dictionary<string, AtsDocumentationInfo> _handleDocumentation = new(StringComparer.Ordinal);
// Short C# type name used in documentation text → generated Go type name. A short name shared by two
// generated types maps to null so it is never turned into a doc link to the wrong type.
private readonly Dictionary<string, string?> _docTypeNames = new(StringComparer.Ordinal);
private GoDocComment _docs = null!;
// typeId → accepting interface name (e.g., "AnyKubernetesNodePoolResource"). Present only for
// concrete handle types that at least one other concrete handle type derives from.
private readonly Dictionary<string, string> _acceptingNames = new(StringComparer.Ordinal);
// typeId → marker method names (e.g., "isKubernetesNodePoolResource") that the concrete type
// declares and implements: one per accepting interface of the type itself or of a base type.
// The accepting interface of the type declares the same set.
private readonly Dictionary<string, List<string>> _acceptingMarkers = new(StringComparer.Ordinal);
/// <inheritdoc />
public string Language => "Go";
/// <inheritdoc />
public Dictionary<string, string> GenerateDistributedApplication(AtsContext context)
{
context = FilterObsoleteSymbols(context);
var files = new Dictionary<string, string>(StringComparer.Ordinal)
{
["go.mod"] = """
module apphost/modules/aspire
go 1.26
""",
["transport.go"] = GetEmbeddedResource("transport.go"),
["base.go"] = GetEmbeddedResource("base.go"),
["aspire.go"] = GenerateAspireSdk(context)
};
foreach (var fileName in files.Keys.Where(fileName => fileName.EndsWith(".go", StringComparison.Ordinal)).ToList())
{
files[fileName] = FormatGoSource(fileName, files[fileName]);
}
// gofmt writes LF, and non-Go module files must use the same deterministic line endings.
return files.ToDictionary(file => file.Key, file => file.Value.Replace("\r\n", "\n"), StringComparer.Ordinal);
}
private static AtsContext FilterObsoleteSymbols(AtsContext context)
{
var capabilities = context.Capabilities.Where(capability => !IsObsoleteCapability(context, capability)).ToList();
var exportedValues = context.ExportedValues.Where(value => !AtsTypeSystemCompatibility.IsObsolete(value)).ToList();
var requiredTypeIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var capability in capabilities)
{
CollectRequiredType(capability.TargetType, requiredTypeIds);
CollectRequiredType(capability.ReturnType, requiredTypeIds);
foreach (var expandedTarget in capability.ExpandedTargetTypes)
{
CollectRequiredType(expandedTarget, requiredTypeIds);
}
foreach (var parameter in capability.Parameters)
{
CollectRequiredType(parameter.Type, requiredTypeIds);
CollectRequiredType(parameter.CallbackReturnType, requiredTypeIds);
foreach (var callbackParameter in parameter.CallbackParameters ?? [])
{
CollectRequiredType(callbackParameter.Type, requiredTypeIds);
}
}
}
foreach (var value in exportedValues)
{
CollectRequiredType(value.Type, requiredTypeIds);
}
foreach (var dto in context.DtoTypes.Where(dto => !AtsTypeSystemCompatibility.IsObsolete(dto)))
{
foreach (var property in dto.Properties.Where(property => !AtsTypeSystemCompatibility.IsObsolete(dto, property)))
{
CollectRequiredType(property.Type, requiredTypeIds);
CollectRequiredType(property.CallbackReturnType, requiredTypeIds);
foreach (var callbackParameter in property.CallbackParameters ?? [])
{
CollectRequiredType(callbackParameter.Type, requiredTypeIds);
}
}
}
var requiredDtoIds = new HashSet<string>(requiredTypeIds, StringComparer.Ordinal);
var changed = true;
while (changed)
{
changed = false;
foreach (var dto in context.DtoTypes.Where(dto => requiredDtoIds.Contains(dto.TypeId)))
{
var before = requiredTypeIds.Count;
var properties = AtsTypeSystemCompatibility.IsObsolete(dto)
? dto.Properties
: dto.Properties.Where(property => !AtsTypeSystemCompatibility.IsObsolete(dto, property));
foreach (var property in properties)
{
CollectRequiredType(property.Type, requiredTypeIds);
CollectRequiredType(property.CallbackReturnType, requiredTypeIds);
foreach (var callbackParameter in property.CallbackParameters ?? [])
{
CollectRequiredType(callbackParameter.Type, requiredTypeIds);
}
}
if (requiredTypeIds.Count != before)
{
requiredDtoIds.UnionWith(requiredTypeIds);
changed = true;
}
}
}
var dtoTypes = context.DtoTypes
.Where(dto => !AtsTypeSystemCompatibility.IsObsolete(dto) || requiredTypeIds.Contains(dto.TypeId))
.Select(dto => AtsTypeSystemCompatibility.IsObsolete(dto)
? dto
: new AtsDtoTypeInfo
{
TypeId = dto.TypeId,
Name = dto.Name,
Description = dto.Description,
Documentation = dto.Documentation,
ClrType = dto.ClrType,
Properties = dto.Properties.Where(property => !AtsTypeSystemCompatibility.IsObsolete(dto, property)).ToList()
})
.ToList();
var enumTypes = context.EnumTypes
.Where(enumType => !AtsTypeSystemCompatibility.IsObsolete(enumType) || requiredTypeIds.Contains(enumType.TypeId))
.Select(enumType =>
{
if (AtsTypeSystemCompatibility.IsObsolete(enumType))
{
return enumType;
}
var obsoleteValues = enumType.ValueInfos
.Where(value => AtsTypeSystemCompatibility.IsObsolete(enumType, value))
.Select(value => value.Name)
.ToHashSet(StringComparer.Ordinal);
return new AtsEnumTypeInfo
{
TypeId = enumType.TypeId,
Name = enumType.Name,
ClrType = enumType.ClrType,
Values = enumType.Values.Where(value => !obsoleteValues.Contains(value)).ToList(),
ValueInfos = enumType.ValueInfos.Where(value => !AtsTypeSystemCompatibility.IsObsolete(enumType, value)).ToList(),
Documentation = enumType.Documentation
};
})
.ToList();
var filtered = new AtsContext
{
Capabilities = capabilities,
HandleTypes = context.HandleTypes.Where(type => !AtsTypeSystemCompatibility.IsObsolete(type) || requiredTypeIds.Contains(type.AtsTypeId)).ToList(),
DtoTypes = dtoTypes,
EnumTypes = enumTypes,
ExportedValues = exportedValues,
Diagnostics = context.Diagnostics
};
var capabilityIds = capabilities.Select(capability => capability.CapabilityId).ToHashSet(StringComparer.Ordinal);
foreach (var (capabilityId, method) in context.Methods)
{
if (capabilityIds.Contains(capabilityId))
{
filtered.Methods[capabilityId] = method;
}
}
foreach (var (capabilityId, property) in context.Properties)
{
if (capabilityIds.Contains(capabilityId))
{
filtered.Properties[capabilityId] = property;
}
}
return filtered;
}
private static void CollectRequiredType(AtsTypeRef? type, HashSet<string> requiredTypeIds)
{
if (type is null)
{
return;
}
requiredTypeIds.Add(type.TypeId);
CollectRequiredType(type.ElementType, requiredTypeIds);
CollectRequiredType(type.KeyType, requiredTypeIds);
CollectRequiredType(type.ValueType, requiredTypeIds);
CollectRequiredType(type.BaseType, requiredTypeIds);
foreach (var unionType in type.UnionTypes ?? [])
{
CollectRequiredType(unionType, requiredTypeIds);
}
foreach (var implementedInterface in type.ImplementedInterfaces)
{
CollectRequiredType(implementedInterface, requiredTypeIds);
}
}
private static bool IsObsoleteCapability(AtsContext context, AtsCapabilityInfo capability)
{
return capability.IsObsolete
|| context.Properties.TryGetValue(capability.CapabilityId, out var property)
&& AttributeDataReader.GetObsoleteData(property) is not null;
}
private static string FormatGoSource(string fileName, string source)
{
using var process = new Process
{
StartInfo = new ProcessStartInfo("gofmt")
{
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardInputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
ArgumentList = { "-s" }
}
};
process.Start();
var formattedTask = process.StandardOutput.ReadToEndAsync();
var errorTask = process.StandardError.ReadToEndAsync();
process.StandardInput.Write(source);
process.StandardInput.Close();
process.WaitForExit();
var formatted = formattedTask.GetAwaiter().GetResult();
var error = errorTask.GetAwaiter().GetResult();
if (process.ExitCode != 0)
{
throw new InvalidOperationException($"gofmt failed for generated file '{fileName}': {error.Trim()}");
}
return formatted;
}
private static string GetEmbeddedResource(string name)
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = $"Aspire.Hosting.CodeGeneration.Go.Resources.{name}";
using var stream = assembly.GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException($"Embedded resource '{name}' not found.");
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
private string GenerateAspireSdk(AtsContext context)
{
using var stringWriter = new StringWriter(CultureInfo.InvariantCulture);
_writer = stringWriter;
BuildNameMaps(context);
var capabilitiesByDirectTarget = GroupCapabilitiesByDirectTarget(context.Capabilities);
var capabilitiesByConcreteImpl = GroupCapabilitiesByConcreteImpl(context.Capabilities);
WriteHeader();
GenerateEnumTypes(context.EnumTypes);
GenerateDtoTypes(context.DtoTypes);
GenerateExportedValues(context.ExportedValues, context.DtoTypes.ToDictionary(dto => dto.TypeId, StringComparer.Ordinal));
GenerateMarkerInterfaces(capabilitiesByDirectTarget);
GenerateAcceptingInterfaces();
GenerateConcreteHandleTypes(capabilitiesByConcreteImpl);
GenerateOptionsStructs(context.Capabilities);
GenerateCreateBuilderOptions(context);
GenerateRegisterWrappers();
GenerateCreateBuilder(context);
return stringWriter.ToString();
}
// ── Name resolution ──────────────────────────────────────────────────────
private void BuildNameMaps(AtsContext context)
{
_interfaceNames.Clear();
_implNames.Clear();
_isInterfaceType.Clear();
_isResourceBuilder.Clear();
_dtoNames.Clear();
_enumNames.Clear();
_acceptingNames.Clear();
_acceptingMarkers.Clear();
_reservedNames.Clear();
foreach (var resourceName in new[] { "base.go", "transport.go" })
{
foreach (var declaration in GoDeclarationScanner.ScanRuntime(GetEmbeddedResource(resourceName)))
{
if (declaration.Kind is GoDeclarationKind.Type or GoDeclarationKind.Function or GoDeclarationKind.Constant)
{
_reservedNames.Add(declaration.Name);
}
}
}
foreach (var enumType in context.EnumTypes)
{
_enumNames[enumType.TypeId] = ReserveName(SanitizeIdentifier(enumType.Name), takenLowercase: false);
}
foreach (var dto in context.DtoTypes)
{
if (dto.TypeId == AtsConstants.ReferenceExpressionTypeId)
{
continue; // ReferenceExpression is provided by base.go
}
_dtoNames[dto.TypeId] = ReserveName(SanitizeIdentifier(dto.Name), takenLowercase: false);
}
// Walk metadata to discover all handle type IDs we need to emit. We
// include both context.HandleTypes (the directly-discovered handles) and
// any types referenced by capabilities (target / return / parameter /
// expanded targets / callback parameters).
var handleTypeIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var handleType in context.HandleTypes)
{
if (handleType.AtsTypeId == AtsConstants.ReferenceExpressionTypeId
|| IsCancellationTokenTypeId(handleType.AtsTypeId))
{
continue;
}
handleTypeIds.Add(handleType.AtsTypeId);
_isInterfaceType[handleType.AtsTypeId] = handleType.IsInterface;
_isResourceBuilder[handleType.AtsTypeId] = handleType.IsResourceBuilder;
}
// Type IDs that appear in any ExpandedTargetTypes. Even if the
// metadata classifies them as interfaces (e.g. IDistributedApplicationBuilder
// whose ExpandedTargetTypes is just [itself]), they need a private impl
// struct so capability methods have somewhere to live.
var expansionTargetIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var capability in context.Capabilities)
{
CollectHandleTypeIds(handleTypeIds, capability.TargetType);
CollectHandleTypeIds(handleTypeIds, capability.ReturnType);
foreach (var parameter in capability.Parameters)
{
CollectHandleTypeIds(handleTypeIds, parameter.Type);
if (parameter.IsCallback && parameter.CallbackParameters is not null)
{
foreach (var cbParam in parameter.CallbackParameters)
{
CollectHandleTypeIds(handleTypeIds, cbParam.Type);
}
}
}
foreach (var expanded in capability.ExpandedTargetTypes)
{
CollectHandleTypeIds(handleTypeIds, expanded);
if (!string.IsNullOrEmpty(expanded.TypeId))
{
expansionTargetIds.Add(expanded.TypeId);
}
}
}
// Sort to get deterministic output. Reserve interface names first (they
// get the bare name); concrete types fall back to suffix-based
// disambiguation when a collision occurs.
var ordered = handleTypeIds
.OrderBy(id => _isInterfaceType.TryGetValue(id, out var isI) && isI ? 0 : 1)
.ThenBy(id => id, StringComparer.Ordinal)
.ToList();
foreach (var typeId in ordered)
{
var isInterface = _isInterfaceType.TryGetValue(typeId, out var ii) && ii;
var needsImpl = !isInterface || expansionTargetIds.Contains(typeId);
var rawName = ExtractTypeName(typeId);
var stripped = StripInterfacePrefix(rawName, isInterface);
var sanitized = SanitizeIdentifier(stripped);
string interfaceName;
if (isInterface && !needsImpl)
{
interfaceName = ReserveName(sanitized, takenLowercase: false);
}
else
{
// Need both an interface and an impl: try bare, then
// assembly-prefixed, then numeric.
if (!IsNameTaken(sanitized))
{
interfaceName = ReserveName(sanitized, takenLowercase: true);
}
else
{
var assemblyPrefix = SanitizeIdentifier(typeId.Split('/')[0]);
var prefixed = $"{assemblyPrefix}{sanitized}";
interfaceName = !IsNameTaken(prefixed)
? ReserveName(prefixed, takenLowercase: true)
: ReserveName(sanitized, takenLowercase: true);
}
}
_interfaceNames[typeId] = interfaceName;
if (needsImpl)
{
_implNames[typeId] = ToCamelCase(interfaceName);
}
}
// Resolve Options struct names for all capabilities that have optional params.
// Must run after DTOs, enums, and handle types are all reserved so collision
// detection via IsNameTaken() is accurate.
_optionsTypeNames.Clear();
var optionsNamesInUse = new HashSet<string>(StringComparer.Ordinal);
// Tracks the first capability assigned to each Options name so subsequent
// capabilities can compare field signatures before sharing.
var optionsNameFirstCapability = new Dictionary<string, AtsCapabilityInfo>(StringComparer.Ordinal);
foreach (var capability in context.Capabilities)
{
var targetParamName = capability.TargetParameterName ?? "builder";
var reservationOptionals = capability.Parameters
.Where(p => !string.Equals(p.Name, targetParamName, StringComparison.Ordinal))
.Where(p => IsCancellationToken(p) || p.IsOptional)
.ToList();
if (reservationOptionals.Count == 0)
{
continue;
}
var simpleName = $"{ToPascalCase(capability.MethodName)}Options";
string resolvedName;
if (!IsNameTaken(simpleName) && !optionsNamesInUse.Contains(simpleName))
{
// Name is free everywhere — use simple name and mark it used.
resolvedName = simpleName;
optionsNamesInUse.Add(simpleName);
optionsNameFirstCapability[simpleName] = capability;
}
else if (optionsNamesInUse.Contains(simpleName) && !IsNameTaken(simpleName))
{
// Already claimed by a previous Options struct (same method name on a different
// type). Reuse ONLY if the optional-param field signatures are compatible.
// Incompatible signatures (e.g. RunAsEmulator on Storage vs EventHubs both have
// configureContainer but with different emulator callback types) must get
// separate qualified structs — sharing would produce a type mismatch in the
// generated callback shim.
if (optionsNameFirstCapability.TryGetValue(simpleName, out var firstCap)
&& AreOptionsCompatible(capability, firstCap))
{
resolvedName = simpleName; // same field shapes — safe to share
}
else
{
// Incompatible callback types — qualify with target interface name.
var qualifiedName = GetOptionsQualifiedName(capability, simpleName);
resolvedName = qualifiedName;
optionsNamesInUse.Add(resolvedName);
optionsNameFirstCapability[resolvedName] = capability;
}
}
else
{
// Collision with a DTO, enum, or handle type. Qualify with the target interface name.
var qualifiedName = GetOptionsQualifiedName(capability, simpleName);
resolvedName = qualifiedName;
optionsNamesInUse.Add(resolvedName);
optionsNameFirstCapability[resolvedName] = capability;
}
_optionsTypeNames[capability.CapabilityId] = resolvedName;
}
// Generated option interfaces and values are package-level declarations too. Reserve all names
// once, before emission, so independently derived names cannot collide with each other or with
// exported values and enum constants.
_reservedNames.UnionWith(_optionsTypeNames.Values);
_reservedNames.UnionWith(BuildExportedValueTree(context.ExportedValues).Children.Keys);
foreach (var enumType in context.EnumTypes)
{
if (enumType.ClrType is null)
{
continue;
}
foreach (var member in enumType.Values)
{
_reservedNames.Add(GetEnumMemberConstantName(_enumNames[enumType.TypeId], member));
}
}
_optionInterfaceNames.Clear();
_optionValueNames.Clear();
var interfaceNamesByOptionsType = new Dictionary<string, string>(StringComparer.Ordinal);
var valueNamesByOptionsField = new Dictionary<(string OptionsType, string FieldName), string>();
foreach (var capability in context.Capabilities)
{
if (!_optionsTypeNames.TryGetValue(capability.CapabilityId, out var optionsType))
{
continue;
}
if (!interfaceNamesByOptionsType.TryGetValue(optionsType, out var optionInterface))
{
var candidate = optionsType.EndsWith("Options", StringComparison.Ordinal)
? optionsType[..^1]
: optionsType + "Option";
optionInterface = ReserveName(candidate, takenLowercase: false);
interfaceNamesByOptionsType[optionsType] = optionInterface;
}
_optionInterfaceNames[capability.CapabilityId] = optionInterface;
var targetParamName = capability.TargetParameterName ?? "builder";
foreach (var parameter in capability.Parameters.Where(parameter =>
!string.Equals(parameter.Name, targetParamName, StringComparison.Ordinal)
&& (IsCancellationToken(parameter) || parameter.IsOptional)))
{
var fieldName = ToPascalCase(parameter.Name);
var sharedKey = (optionsType, fieldName);
if (!valueNamesByOptionsField.TryGetValue(sharedKey, out var optionValue))
{
var candidate = GetOptionValueNameCandidate(optionsType, fieldName);
if (candidate == optionsType || candidate == optionInterface)
{
candidate += "Value";
}
optionValue = ReserveName(candidate, takenLowercase: true);
valueNamesByOptionsField[sharedKey] = optionValue;
}
_optionValueNames[(capability.CapabilityId, parameter.Name)] = optionValue;
}
}
_createBuilderOptionInterfaceName = null;
_createBuilderOptionValueNames.Clear();
if (context.DtoTypes.FirstOrDefault(dto => dto.Name == "CreateBuilderOptions") is { } createBuilderOptions)
{
_createBuilderOptionInterfaceName = ReserveName("CreateBuilderOption", takenLowercase: false);
foreach (var property in createBuilderOptions.Properties)
{
var candidate = $"CreateBuilder{ToPascalCase(property.Name)}";
_createBuilderOptionValueNames[property.Name] = ReserveName(candidate, takenLowercase: false);
}
}
BuildAcceptingInterfaces(context);
// Every doc render dereferences _docs, and the renderer resolves type names through the maps
// built above. Building it here means any caller that builds the name maps also gets a usable
// renderer, instead of failing with a NullReferenceException the first time a doc is rendered.
BuildDocumentationMaps(context);
}
// Go has no return-type covariance, so the interface of a derived handle type never satisfies the
// interface of its base type: each interface re-declares the fluent methods with its own return
// type. A parameter typed as a base (WithNodePool takes KubernetesNodePoolResource) would therefore
// reject the derived builder AddNodePool returns (AksNodePoolResource).
//
// Every concrete handle type that another concrete handle type derives from gets an accepting
// interface, Any<Type>, used only where a caller supplies a value. It embeds handleReference and
// declares unexported marker methods: the marker of the type itself and of each base type that has
// an accepting interface too. The type and each of its derived types implement all of them. That
// keeps the check at compile time (a builder outside the family lacks the marker) without changing
// the fluent interface of any type. The base-type markers are what let an Any<Type> value be
// passed where the Any<Base> of one of its base types is expected, because Go converts an
// interface value to another interface only when its method set has every method of the target.
//
// A type that nothing derives from gets no accepting interface: its own interface already accepts
// exactly the same values. The cost is that the set of accepting interfaces, and which parameters
// use them, follows the handle types of the integrations an AppHost includes. The generated
// header (see GenerateAcceptingInterfaces) tells users so.
private void BuildAcceptingInterfaces(AtsContext context)
{
var ancestorIdsByType = new Dictionary<string, List<string>>(StringComparer.Ordinal);
var baseTypeIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var handleType in context.HandleTypes)
{
if (!_implNames.ContainsKey(handleType.AtsTypeId))
{
continue;
}
// BaseTypeHierarchy lists every base class, including ones that are not emitted as handle
// types (for example the abstract Resource class); those have no Go type to accept.
var ancestorIds = handleType.BaseTypeHierarchy
.Select(baseType => baseType.TypeId)
.Where(_implNames.ContainsKey)
.ToList();
ancestorIdsByType[handleType.AtsTypeId] = ancestorIds;
baseTypeIds.UnionWith(ancestorIds);
}
if (baseTypeIds.Count == 0)
{
return;
}
foreach (var typeId in baseTypeIds.OrderBy(id => _interfaceNames[id], StringComparer.Ordinal))
{
_acceptingNames[typeId] = ReserveName($"Any{_interfaceNames[typeId]}", takenLowercase: false);
}
// Every type with an implementation takes part, including a base type that capabilities
// reference but HandleTypes does not list. It has no known ancestors, yet it still has an
// accepting interface and must implement that interface's own marker.
foreach (var typeId in _implNames.Keys)
{
var markers = (ancestorIdsByType.GetValueOrDefault(typeId) ?? [])
.Append(typeId)
.Where(_acceptingNames.ContainsKey)
.Select(GetAcceptingMarkerName)
.Order(StringComparer.Ordinal)
.ToList();
if (markers.Count > 0)
{
_acceptingMarkers[typeId] = markers;
}
}
}
private string GetAcceptingMarkerName(string typeId) => $"is{_interfaceNames[typeId]}";
private static void CollectHandleTypeIds(HashSet<string> set, AtsTypeRef? typeRef)
{
if (typeRef is null)
{
return;
}
if (typeRef.TypeId == AtsConstants.ReferenceExpressionTypeId
|| IsCancellationTokenTypeId(typeRef.TypeId))
{
return;
}
if (typeRef.Category == AtsTypeCategory.Handle)
{
set.Add(typeRef.TypeId);
}
if (typeRef.ElementType is not null)
{
CollectHandleTypeIds(set, typeRef.ElementType);
}
if (typeRef.KeyType is not null)
{
CollectHandleTypeIds(set, typeRef.KeyType);
}
if (typeRef.ValueType is not null)
{
CollectHandleTypeIds(set, typeRef.ValueType);
}
if (typeRef.UnionTypes is not null)
{
foreach (var u in typeRef.UnionTypes)
{
CollectHandleTypeIds(set, u);
}
}
}
private static string StripInterfacePrefix(string name, bool isInterface)
{
if (!isInterface || name.Length < 2 || name[0] != 'I')
{
return name;
}
if (!char.IsUpper(name[1]))
{
return name;
}
return name[1..];
}
private readonly HashSet<string> _reservedNames = new(StringComparer.Ordinal);
private bool IsNameTaken(string name) => _reservedNames.Contains(name);
private string ReserveName(string candidate, bool takenLowercase)
{
var name = candidate;
var counter = 2;
while (_reservedNames.Contains(name)
|| (takenLowercase && _reservedNames.Contains(ToCamelCase(name))))
{
name = $"{candidate}{counter}";
counter++;
}
_reservedNames.Add(name);
if (takenLowercase)
{
_reservedNames.Add(ToCamelCase(name));
}
return name;
}
// ── Capability grouping ───────────────────────────────────────────────────
/// <summary>
/// Groups capabilities by their direct target type ID (no expansion).
/// Used to emit interface-metadata declarations: methods listed here go on
/// the marker interface for the target.
/// </summary>
private static Dictionary<string, List<AtsCapabilityInfo>> GroupCapabilitiesByDirectTarget(
IReadOnlyList<AtsCapabilityInfo> capabilities)
{
var result = new Dictionary<string, List<AtsCapabilityInfo>>(StringComparer.Ordinal);
foreach (var capability in capabilities)
{
if (string.IsNullOrEmpty(capability.TargetTypeId))
{
continue;
}
if (!result.TryGetValue(capability.TargetTypeId, out var list))
{
list = new List<AtsCapabilityInfo>();
result[capability.TargetTypeId] = list;
}
list.Add(capability);
}
return result;
}
/// <summary>
/// Groups capabilities by every concrete type they end up applying to.
/// Uses ExpandedTargetTypes when the target is an interface; falls back to
/// TargetType otherwise. Used to emit method bodies on each concrete impl.
/// </summary>
private Dictionary<string, List<AtsCapabilityInfo>> GroupCapabilitiesByConcreteImpl(
IReadOnlyList<AtsCapabilityInfo> capabilities)
{
var result = new Dictionary<string, List<AtsCapabilityInfo>>(StringComparer.Ordinal);
foreach (var capability in capabilities)
{
if (string.IsNullOrEmpty(capability.TargetTypeId))
{
continue;
}
var targetTypes = capability.ExpandedTargetTypes.Count > 0
? capability.ExpandedTargetTypes
: capability.TargetType is not null
? [capability.TargetType]
: (IReadOnlyList<AtsTypeRef>)Array.Empty<AtsTypeRef>();
foreach (var targetType in targetTypes)
{
if (string.IsNullOrEmpty(targetType.TypeId))
{
continue;
}
// Emit on any type that has an impl struct — concrete types
// and self-referencing interfaces (e.g. IDistributedApplicationBuilder).
if (!_implNames.ContainsKey(targetType.TypeId))
{
continue;
}
if (!result.TryGetValue(targetType.TypeId, out var list))
{
list = new List<AtsCapabilityInfo>();
result[targetType.TypeId] = list;
}
list.Add(capability);
}
}
return result;
}
// ── Header / imports ─────────────────────────────────────────────────────
private void WriteHeader()
{
WriteLine("""
// aspire.go - Capability-based Aspire SDK
// This SDK uses the ATS (Aspire Type System) capability API.
// Capabilities are endpoints like 'Aspire.Hosting/createBuilder'.
//
// GENERATED CODE - DO NOT EDIT
package aspire
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"time"
)
// Compile-time references to keep imports used in minimal SDKs.
var _ = context.Background
var _ = json.Number("")
var _ = fmt.Errorf
var _ = os.Getenv
var _ = strings.EqualFold
var _ = time.Second
""");
WriteLine();
}
// ── Documentation ────────────────────────────────────────────────────────
private void BuildDocumentationMaps(AtsContext context)
{
_handleDocumentation.Clear();
_docTypeNames.Clear();
foreach (var handleType in context.HandleTypes)
{
if (handleType.Documentation is { } documentation)
{
_handleDocumentation[handleType.AtsTypeId] = documentation;
}
}
foreach (var (typeId, goName) in _interfaceNames)
{
AddDocTypeName(ExtractTypeName(typeId), goName);
}
foreach (var dto in context.DtoTypes)
{
if (_dtoNames.TryGetValue(dto.TypeId, out var dtoName))
{
AddDocTypeName(dto.Name, dtoName);
}
}
foreach (var enumType in context.EnumTypes)
{
if (_enumNames.TryGetValue(enumType.TypeId, out var enumName))
{
AddDocTypeName(enumType.Name, enumName);
}
}
_docs = new GoDocComment(name => _docTypeNames.GetValueOrDefault(name), ToPascalCase);
}
private void AddDocTypeName(string shortName, string goName)
{
if (!_docTypeNames.TryAdd(shortName, goName) && _docTypeNames[shortName] != goName)
{
_docTypeNames[shortName] = null;
}
}
private void WriteDocLines(IReadOnlyList<string> lines, string indent = "")
{
foreach (var line in lines)
{
WriteLine($"{indent}{line}");
}
}
private GoDocContent CreateCapabilityDoc(AtsCapabilityInfo capability)
{
var targetParamName = capability.TargetParameterName ?? "builder";
var (requiredParams, optionalParams) = SplitParameters(capability.Parameters
.Where(p => !string.Equals(p.Name, targetParamName, StringComparison.Ordinal))
.ToList());
// A setter's only parameter carries a copy of the property documentation ("Gets or sets the port."),
// which is already the summary and reads wrongly as a description of the argument.
var isSetter = capability.CapabilityKind == AtsCapabilityKind.PropertySetter;
var parameters = requiredParams
.Select(p => new GoDocParameter(p.Name, GetLocalIdentifier(p.Name), isSetter ? null : p.Documentation?.Summary))
.ToList();
if (optionalParams.Count > 0)
{
parameters.Add(new GoDocParameter(
"options",
"options",
$"Optional parameters; see {RenderTypeLink(GetOptionInterfaceName(capability))}."));
}
var returnsValue = capability.ReturnType is not null && capability.ReturnType.TypeId != AtsConstants.Void;
return new GoDocContent
{
GoName = ToPascalCase(capability.MethodName),
Subject = capability.CapabilityKind switch
{
AtsCapabilityKind.PropertyGetter => GoDocSubject.Getter,
AtsCapabilityKind.PropertySetter => GoDocSubject.Setter,
_ => GoDocSubject.Method,
},
Summary = capability.Documentation?.Summary,
FallbackSummary = capability.Description,
Remarks = capability.Documentation?.Remarks,
Parameters = parameters,
Returns = returnsValue ? capability.Documentation?.Returns : null,
Notes = GetUnionNotes(capability),
IsExperimental = AtsTypeSystemCompatibility.IsExperimental(capability),
IsObsolete = capability.IsObsolete,
ObsoleteMessage = capability.ObsoleteMessage,
};
}
// A doc link only resolves to a declared package-level identifier; anything else (such as the
// map[string]any fallback for an unknown DTO) is written as plain text.
private static string RenderTypeLink(string goType)
{
return goType.All(c => char.IsLetterOrDigit(c) || c == '_') ? $"[{goType}]" : goType;
}
// ── Enums ────────────────────────────────────────────────────────────────
private void GenerateEnumTypes(IReadOnlyList<AtsEnumTypeInfo> enumTypes)
{
if (enumTypes.Count == 0)
{
return;
}
WriteLine("// ============================================================================");
WriteLine("// Enums");
WriteLine("// ============================================================================");
WriteLine();
foreach (var enumType in enumTypes)
{
if (enumType.ClrType is null)
{
continue;
}
var enumName = _enumNames[enumType.TypeId];
WriteDocLines(_docs.Render(new GoDocContent
{
GoName = enumName,
Subject = GoDocSubject.Type,
Summary = enumType.Documentation?.Summary,
FallbackSummary = $"{enumName} represents {enumType.Name}.",
Remarks = enumType.Documentation?.Remarks,
}));
WriteLine($"type {enumName} string");
WriteLine();
WriteLine("const (");
var valueDocumentation = enumType.ValueInfos.ToDictionary(value => value.Name, value => value.Documentation, StringComparer.Ordinal);
foreach (var member in enumType.Values)
{
var memberName = GetEnumMemberConstantName(enumName, member);
if (valueDocumentation.TryGetValue(member, out var memberDocumentation))
{
WriteDocLines(_docs.Render(new GoDocContent
{
GoName = memberName,
Subject = GoDocSubject.Field,
Summary = memberDocumentation?.Summary,
Remarks = memberDocumentation?.Remarks,
}), "\t");
}
WriteLine($"\t{memberName} {enumName} = {GoStringLiteral(member)}");
}
WriteLine(")");
WriteLine();
}
}
private static string GetEnumMemberConstantName(string enumName, string member) => $"{enumName}{ToPascalCase(member)}";
// ── DTOs ─────────────────────────────────────────────────────────────────
private void GenerateDtoTypes(IReadOnlyList<AtsDtoTypeInfo> dtoTypes)
{
if (dtoTypes.Count == 0)
{
return;
}
WriteLine("// ============================================================================");
WriteLine("// DTOs");
WriteLine("// ============================================================================");
WriteLine();
foreach (var dto in dtoTypes)
{
if (dto.TypeId == AtsConstants.ReferenceExpressionTypeId)
{
continue;
}
var dtoName = _dtoNames[dto.TypeId];
WriteDocLines(_docs.Render(new GoDocContent
{
GoName = dtoName,
Subject = GoDocSubject.Type,
Summary = dto.Documentation?.Summary ?? dto.Description,
FallbackSummary = $"{dtoName} represents {dto.Name}.",
Remarks = dto.Documentation?.Remarks,
}));
WriteLine($"type {dtoName} struct {{");
if (dto.Properties.Count == 0)
{
WriteLine("}");
WriteLine();
EmitEmptyToMap(dtoName);
continue;
}
foreach (var property in dto.Properties)
{
var propertyName = ToPascalCase(property.Name);
// Callback-typed DTO properties carry the same metadata as method parameters, so
// render the strongly-typed func signature (e.g. func(ctx InputsDialogValidationContext))
// instead of the weak func(...any) any fallback.
var propertyType = property.IsCallback
? RenderCallbackType(DtoPropertyToParameterInfo(property))
: MapDtoPropertyTypeToGo(property.Type, property.IsOptional);
var jsonTag = $"`json:{GoStringLiteral(JsonNamingPolicy.CamelCase.ConvertName(property.Name) + ",omitempty")}`";
WriteDocLines(_docs.Render(new GoDocContent
{
GoName = propertyName,
Subject = GoDocSubject.Field,
Summary = property.Documentation?.Summary ?? property.Description,
Remarks = property.Documentation?.Remarks,
}), "\t");
WriteLine($"\t{propertyName} {propertyType} {jsonTag}");
}
WriteLine("}");
WriteLine();
WriteLine($"// ToMap converts the DTO to a map for JSON serialization.");
WriteLine($"func (d *{dtoName}) ToMap() map[string]any {{");
WriteLine("\tm := map[string]any{}");
foreach (var property in dto.Properties)
{
var propertyName = ToPascalCase(property.Name);
if (property.IsCallback)
{
EmitDtoCallbackToMap(property, propertyName);
continue;
}
var propertyType = MapDtoPropertyTypeToGo(property.Type, property.IsOptional);
if (IsNilableGoType(propertyType))
{
WriteLine($"\tif d.{propertyName} != nil {{ m[{GoStringLiteral(JsonNamingPolicy.CamelCase.ConvertName(property.Name))}] = serializeValue(d.{propertyName}) }}");
}
else
{
WriteLine($"\tm[{GoStringLiteral(JsonNamingPolicy.CamelCase.ConvertName(property.Name))}] = serializeValue(d.{propertyName})");
}
}
WriteLine("\treturn m");
WriteLine("}");
WriteLine();
}
}
private void GenerateExportedValues(
IReadOnlyList<AtsExportedValueInfo> exportedValues,
IReadOnlyDictionary<string, AtsDtoTypeInfo> dtoTypesById)
{
if (exportedValues.Count == 0)
{
return;
}
var root = BuildExportedValueTree(exportedValues);
WriteLine("// ============================================================================");
WriteLine("// Exported Values");
WriteLine("// ============================================================================");
WriteLine();
foreach (var (name, node) in root.Children.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
WriteLine($"// {name} groups related exported values.");
WriteLine($"var {name} = {RenderGoExportedValueType(node, indentLevel: 0, includeDocs: true)}{RenderGoExportedValueValue(node, dtoTypesById, indentLevel: 0)}");
WriteLine();
}
}
// The type literal is written twice for nested groups: once as the variable's type and once more in front
// of each nested value. Only the variable's type carries documentation, because that is the declaration
// go doc and gopls resolve a field to.
private string RenderGoExportedValueType(GoExportedValueTreeNode node, int indentLevel, bool includeDocs)
{
var indent = new string('\t', indentLevel);
var nestedIndent = new string('\t', indentLevel + 1);
var sb = new StringBuilder();
sb.AppendLine("struct {");
foreach (var (name, child) in node.Children.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
if (child.Value is { } valueInfo)
{
if (includeDocs)
{
foreach (var line in _docs.Render(new GoDocContent
{
GoName = name,
Subject = GoDocSubject.Field,
Summary = valueInfo.Documentation?.Summary ?? valueInfo.Description,
Remarks = valueInfo.Documentation?.Remarks,
}))
{
sb.Append(nestedIndent);
sb.AppendLine(line);
}
}
sb.Append(nestedIndent);
sb.Append(name);
sb.Append(' ');
sb.AppendLine(MapTypeRefToGo(valueInfo.Type, isOptional: false));
}
else
{
sb.Append(nestedIndent);
sb.Append(name);
sb.Append(' ');
sb.Append(RenderGoExportedValueType(child, indentLevel + 1, includeDocs));
sb.AppendLine();
}
}
sb.Append(indent);
sb.Append('}');
return sb.ToString();
}
private string RenderGoExportedValueValue(
GoExportedValueTreeNode node,
IReadOnlyDictionary<string, AtsDtoTypeInfo> dtoTypesById,
int indentLevel)
{
var indent = new string('\t', indentLevel);
var nestedIndent = new string('\t', indentLevel + 1);
var sb = new StringBuilder();
sb.AppendLine("{");
foreach (var (name, child) in node.Children.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
sb.Append(nestedIndent);
sb.Append(name);
sb.Append(": ");
if (child.Value is { } valueInfo)
{
sb.Append(RenderGoExportedValue(valueInfo.Value, valueInfo.Type, dtoTypesById));
}
else
{
sb.Append(RenderGoExportedValueType(child, indentLevel + 1, includeDocs: false));
sb.Append(RenderGoExportedValueValue(child, dtoTypesById, indentLevel + 1));
}
sb.AppendLine(",");
}
sb.Append(indent);
sb.Append('}');
return sb.ToString();
}
private string RenderGoExportedValue(
JsonNode? value,
AtsTypeRef typeRef,
IReadOnlyDictionary<string, AtsDtoTypeInfo> dtoTypesById)
{
if (value is null)
{
return "nil";
}
var renderedValue = typeRef.Category switch
{
AtsTypeCategory.Primitive => RenderGoJsonValue(value),
AtsTypeCategory.Enum => $"{MapEnumType(typeRef.TypeId)}({RenderGoJsonValue(value)})",
AtsTypeCategory.Dto when value is JsonObject obj && dtoTypesById.TryGetValue(typeRef.TypeId, out var dtoInfo)
=> RenderGoDtoValue(obj, dtoInfo, dtoTypesById),
AtsTypeCategory.Array or AtsTypeCategory.List when value is JsonArray arr
=> $"[]{MapTypeRefToGo(typeRef.ElementType, isOptional: false)}{{{string.Join(", ", arr.Select(item => RenderGoExportedValue(item, typeRef.ElementType!, dtoTypesById)))}}}",
AtsTypeCategory.Dict when value is JsonObject obj
=> $"map[{MapTypeRefToGo(typeRef.KeyType, isOptional: false)}]{MapTypeRefToGo(typeRef.ValueType, isOptional: false)}{{{string.Join(", ", obj.Select(pair => $"{GoStringLiteral(pair.Key)}: {RenderGoExportedValue(pair.Value, typeRef.ValueType!, dtoTypesById)}"))}}}",
_ => RenderGoJsonValue(value)
};
if (!ShouldApplyNullableType(typeRef))
{
return renderedValue;
}
var valueType = typeRef.Category == AtsTypeCategory.Primitive
? MapPrimitiveType(typeRef)
: MapEnumType(typeRef.TypeId);
return $"func(value {valueType}) *{valueType} {{ return &value }}({renderedValue})";
}
// The relaxed JSON writer spells a character outside the Basic Multilingual Plane as a UTF-16 surrogate pair
// ("\uD83D\uDE00"), and Go rejects surrogate halves in a string literal. Strings are therefore quoted by
// GoStringLiteral; numbers and booleans are the same text in JSON and Go.
private static string RenderGoJsonValue(JsonNode value) =>
value is JsonValue jsonValue && jsonValue.TryGetValue<string>(out var text)
? GoStringLiteral(text)
: value.ToRelaxedJsonString();
private string RenderGoDtoValue(
JsonObject value,
AtsDtoTypeInfo dtoInfo,
IReadOnlyDictionary<string, AtsDtoTypeInfo> dtoTypesById)
{
var sb = new StringBuilder();
sb.Append('&');
sb.Append(_dtoNames[dtoInfo.TypeId]);
sb.Append('{');
var members = new List<string>();
foreach (var property in dtoInfo.Properties)
{
if (!value.TryGetPropertyValue(property.Name, out var propertyValue))
{
continue;
}
members.Add($"{ToPascalCase(property.Name)}: {RenderGoExportedValue(propertyValue, property.Type, dtoTypesById)}");
}
sb.Append(string.Join(", ", members));
sb.Append('}');
return sb.ToString();
}
private static GoExportedValueTreeNode BuildExportedValueTree(IReadOnlyList<AtsExportedValueInfo> exportedValues)
{
var root = new GoExportedValueTreeNode();
foreach (var exportedValue in exportedValues)
{
var current = root;
foreach (var segment in exportedValue.PathSegments.Select(SanitizeIdentifier))
{
if (!current.Children.TryGetValue(segment, out var child))
{
child = new GoExportedValueTreeNode();
current.Children[segment] = child;
}
current = child;
}
current.Value = exportedValue;
}
return root;
}
private void EmitEmptyToMap(string dtoName)
{
WriteLine($"// ToMap converts the DTO to a map for JSON serialization.");
WriteLine($"func (d *{dtoName}) ToMap() map[string]any {{ return map[string]any{{}} }}");
WriteLine();
}
// ── Marker interfaces (interface-metadata types) ─────────────────────────
//
// Interface-metadata types are emitted as empty marker interfaces. They
// don't declare methods because Go has no return-type covariance: if we
// declared `WithEnvironment() ResourceWithEnvironment` here, no concrete
// impl could satisfy it while also providing a fluent return of its own
// concrete type. Concrete impls list every method inline (see
// GenerateConcreteHandleTypes), and capability returns of an interface
// type fall back to the marker interface, which any concrete satisfies
// trivially.
// The interface-metadata types that get only a marker interface because no concrete impl is
// generated for them, ordered by Go name.
private List<KeyValuePair<string, string>> GetMarkerInterfaces()
{
return _interfaceNames
.Where(kv => _isInterfaceType.TryGetValue(kv.Key, out var isI) && isI && !_implNames.ContainsKey(kv.Key))
.OrderBy(kv => kv.Value, StringComparer.Ordinal)
.ToList();
}
private void GenerateMarkerInterfaces(Dictionary<string, List<AtsCapabilityInfo>> capabilitiesByDirectTarget)
{
var interfaceTypeIds = GetMarkerInterfaces();
if (interfaceTypeIds.Count == 0)
{
return;
}
WriteLine("// ============================================================================");
WriteLine("// Marker interfaces (from interface-metadata types)");
WriteLine("// ============================================================================");
WriteLine();
foreach (var (typeId, name) in interfaceTypeIds)
{
var hasMethods = capabilitiesByDirectTarget.ContainsKey(typeId);
var documentation = _handleDocumentation.GetValueOrDefault(typeId);
WriteDocLines(_docs.Render(new GoDocContent
{
GoName = name,
Subject = GoDocSubject.Type,
Summary = documentation?.Summary,
FallbackSummary = $"{name} marks types implementing {ExtractTypeName(typeId)}.",
Remarks = documentation?.Remarks,
Notes = hasMethods ? ["Methods are emitted on concrete impls; this interface is a marker for type assertions."] : [],
}));
WriteLine($"type {name} interface {{");
WriteLine("\thandleReference");
WriteLine("}");
WriteLine();
}
}
// ── Accepting interfaces (concrete handle types with derived types) ──────
//
// See BuildAcceptingInterfaces for why these exist. They are declared before the handle
// wrappers; the marker methods that satisfy them are declared on each handle's interface and
// implemented on its impl (see GenerateConcreteHandleTypes).
private void GenerateAcceptingInterfaces()
{
if (_acceptingNames.Count == 0)
{
return;
}
WriteLine("// ============================================================================");
WriteLine("// Accepting interfaces (handle types with derived types)");
WriteLine("// ============================================================================");
WriteLine("// Go has no return-type covariance, so the interface of a derived handle type does not");
WriteLine("// satisfy the interface of its base type. Each Any<Type> below accepts <Type> or a handle");
WriteLine("// of any type derived from it, and is used wherever a method, options struct, or DTO takes");
WriteLine("// a <Type> as input. The unexported marker methods restrict it to that family of types.");
WriteLine("//");
WriteLine("// The set of Any<Type> interfaces depends on the integrations of the AppHost this SDK is");
WriteLine("// generated for: an Any<Type> exists only while some handle type in the SDK derives from <Type>.");
WriteLine("// Adding or removing an integration can add or remove an Any<Type> and change a method parameter");
WriteLine("// from <Type> to Any<Type>, so code that names an Any<Type> or such a parameter's type depends");
WriteLine("// on the integrations the AppHost includes.");
WriteLine();
foreach (var (typeId, name) in _acceptingNames.OrderBy(kv => kv.Value, StringComparer.Ordinal))
{
var interfaceName = _interfaceNames[typeId];
WriteLine($"// {name} accepts {interfaceName} or a handle of any type derived from it.");
WriteLine($"type {name} interface {{");
WriteLine("\thandleReference");
// The markers of the base types are declared too: Go converts an interface value to another
// interface only when its method set has every method of the target, so without them an
// Any<Type> value could not be passed where the Any<Base> of a base type is expected. That
// admits nothing the base type rejects, since every handle of this family belongs to the
// family of each base type. BaseTypeHierarchy lists the whole base chain, so each derived
// type implements every marker declared here.
foreach (var marker in _acceptingMarkers[typeId])
{
WriteLine($"\t{marker}()");
}
WriteLine("}");
WriteLine();
}
}
// ── Concrete handle types: interface + impl + methods ─────────────────────
private void GenerateConcreteHandleTypes(Dictionary<string, List<AtsCapabilityInfo>> capabilitiesByConcreteImpl)
{
var concreteTypeIds = _implNames.Keys.OrderBy(id => _interfaceNames[id], StringComparer.Ordinal).ToList();
if (concreteTypeIds.Count == 0)
{
return;
}
WriteLine("// ============================================================================");
WriteLine("// Handle wrappers");
WriteLine("// ============================================================================");
WriteLine();
foreach (var typeId in concreteTypeIds)
{
var interfaceName = _interfaceNames[typeId];
var implName = _implNames[typeId];
var capabilities = capabilitiesByConcreteImpl.TryGetValue(typeId, out var caps)
? caps.Where(c => !IsSpecialCasedCapability(c.CapabilityId))
.OrderBy(c => c.MethodName, StringComparer.Ordinal)
.ToList()
: new List<AtsCapabilityInfo>();
var listDictGetters = CollectListDictGetters(capabilities);
EmitConcreteInterface(typeId, interfaceName, capabilities, listDictGetters);
EmitConcreteStruct(interfaceName, implName, listDictGetters);
EmitConcreteConstructor(interfaceName, implName);
EmitAcceptingMarkerMethods(typeId, implName);
foreach (var capability in capabilities)
{
EmitCapabilityMethod(typeId, interfaceName, implName, capability, listDictGetters);
}
if (string.Equals(typeId, InteractionInputCollectionTypeId, StringComparison.Ordinal))
{
EmitInteractionInputCollectionAccessors(implName);
}
}
}
private static List<(string Field, string MethodName, AtsTypeRef ReturnType, string CapabilityId, string? Description)>
CollectListDictGetters(IReadOnlyList<AtsCapabilityInfo> capabilities)
{
var result = new List<(string, string, AtsTypeRef, string, string?)>();
foreach (var capability in capabilities)
{
var targetParamName = capability.TargetParameterName ?? "builder";
var nonTargetParams = capability.Parameters
.Where(p => !string.Equals(p.Name, targetParamName, StringComparison.Ordinal))
.ToList();
if (nonTargetParams.Count != 0)
{
continue;
}
if (capability.ReturnType is null)
{
continue;
}
if (capability.ReturnType.Category != AtsTypeCategory.List
&& capability.ReturnType.Category != AtsTypeCategory.Dict)
{
continue;
}
if (capability.ReturnType.IsReadOnly)
{
continue;
}
var methodName = ToPascalCase(capability.MethodName);
var fieldName = ToCamelCase(methodName);
result.Add((fieldName, methodName, capability.ReturnType, capability.CapabilityId, capability.Description));
}
return result;
}
private void EmitConcreteInterface(
string typeId,
string interfaceName,
IReadOnlyList<AtsCapabilityInfo> capabilities,
List<(string Field, string MethodName, AtsTypeRef ReturnType, string CapabilityId, string? Description)> listDictGetters)
{
var documentation = _handleDocumentation.GetValueOrDefault(typeId);
WriteDocLines(_docs.Render(new GoDocContent
{
GoName = interfaceName,
Subject = GoDocSubject.Type,
Summary = documentation?.Summary,
FallbackSummary = $"{interfaceName} is the public interface for handle type {interfaceName}.",
Remarks = documentation?.Remarks,
}));
WriteLine($"type {interfaceName} interface {{");
WriteLine("\thandleReference");
// The interface itself must declare the markers: a value whose static type is this interface
// is assignable to an accepting interface only if its method set includes the marker.
if (_acceptingMarkers.TryGetValue(typeId, out var markers))
{
foreach (var marker in markers)
{
WriteLine($"\t{marker}()");
}
}
// Capability methods (skip list/dict getters — they're rendered as
// distinct accessor methods below).
var listDictCapIds = listDictGetters.Select(g => g.CapabilityId).ToHashSet(StringComparer.Ordinal);
foreach (var capability in capabilities)
{
if (listDictCapIds.Contains(capability.CapabilityId))
{
continue;
}
WriteInterfaceMemberDoc(_docs.Render(CreateCapabilityDoc(capability)));
WriteLine($"\t{RenderMethodSignature(typeId, interfaceName, capability)}");
}
// List/Dict accessor methods.
foreach (var (_, methodName, returnType, capabilityId, _) in listDictGetters)
{
var (wrapperType, typeArgs) = ListDictTypeArgs(returnType);
WriteInterfaceMemberDoc(_docs.Render(CreateCapabilityDoc(capabilities.First(c => c.CapabilityId == capabilityId))));
WriteLine($"\t{methodName}() *{wrapperType}{typeArgs}");
}
// The builder gets a Build() method synthesised by GenerateCreateBuilder;
// declare it here so the public interface exposes it.
if (string.Equals(typeId, AtsConstants.BuilderTypeId, StringComparison.Ordinal))
{
var appInterface = _interfaceNames.TryGetValue(AtsConstants.ApplicationTypeId, out var appName)
? appName
: "DistributedApplication";
WriteInterfaceMemberDoc(RenderGoDoc("Build", BuildSummary));
WriteLine($"\tBuild() ({appInterface}, error)");
}
// By-name accessors are hand-authored convenience helpers layered over the generated
// ToArray capability so polyglot callers get the same ergonomics as the .NET indexer.
if (string.Equals(typeId, InteractionInputCollectionTypeId, StringComparison.Ordinal))
{
WriteInterfaceMemberDoc(RenderGoDoc("Get", InputGetSummary));
WriteLine("\tGet(name string) (*InteractionInput, error)");
WriteInterfaceMemberDoc(RenderGoDoc("Required", InputRequiredSummary));
WriteLine("\tRequired(name string) (*InteractionInput, error)");
WriteInterfaceMemberDoc(RenderGoDoc("Value", InputValueSummary));
WriteLine("\tValue(name string) (string, error)");
WriteInterfaceMemberDoc(RenderGoDoc("RequiredValue", InputRequiredValueSummary));
WriteLine("\tRequiredValue(name string) (string, error)");
}
// Lifecycle escape hatches.
var errSummary = string.Equals(typeId, AtsConstants.BuilderTypeId, StringComparison.Ordinal)
? BuilderErrSummary
: ErrSummary;
WriteInterfaceMemberDoc(RenderGoDoc("Err", errSummary));
WriteLine("\tErr() error");
WriteLine("}");
WriteLine();
}
// A blank line before each documented member keeps a multi-line comment visibly attached to the member below it.
private void WriteInterfaceMemberDoc(IReadOnlyList<string> lines)
{
if (lines.Count == 0)
{
return;
}
WriteLine();
WriteDocLines(lines, "\t");
}
private IReadOnlyList<string> RenderGoDoc(string goName, string summary)
{
return _docs.Render(new GoDocContent { GoName = goName, Summary = summary });
}
private void EmitConcreteStruct(
string interfaceName,
string implName,
List<(string Field, string MethodName, AtsTypeRef ReturnType, string CapabilityId, string? Description)> listDictGetters)
{
WriteLine($"// {implName} is the unexported impl of {interfaceName}.");
WriteLine($"type {implName} struct {{");
WriteLine("\t*resourceBuilderBase");
foreach (var (field, _, returnType, _, _) in listDictGetters)
{
var (wrapperType, typeArgs) = ListDictTypeArgs(returnType);
WriteLine($"\t{field} *{wrapperType}{typeArgs}");
}
WriteLine("}");
WriteLine();
}
private void EmitConcreteConstructor(string interfaceName, string implName)
{
WriteLine($"// new{interfaceName}FromHandle wraps an existing handle as {interfaceName}.");
WriteLine($"func new{interfaceName}FromHandle(h *handle, c *client) {interfaceName} {{");
WriteLine($"\treturn &{implName}{{resourceBuilderBase: newResourceBuilderBase(h, c)}}");
WriteLine("}");
WriteLine();
}
private void EmitAcceptingMarkerMethods(string typeId, string implName)
{
if (!_acceptingMarkers.TryGetValue(typeId, out var markers))
{
return;
}
WriteLine($"// {implName} implements the marker of each accepting interface for its own type and its base types.");
foreach (var marker in markers)
{
WriteLine($"func (*{implName}) {marker}() {{}}");
}
WriteLine();
}
// Type ID of InteractionInputCollection. The by-name accessors below are hand-authored on top of the
// generated ToArray capability so Go matches the .NET indexer and the TypeScript get/value helpers.
private const string InteractionInputCollectionTypeId = "Aspire.Hosting/Aspire.Hosting.InteractionInputCollection";
// Hand-authored members are documented on both the public interface and the implementation.
private const string BuildSummary = "Build invokes the build capability and returns the running application.";
private const string BuilderErrSummary = "Err returns an error combining every failure recorded by this builder or by any handle created from it.";
private const string ErrSummary = "Err returns the first error recorded on this wrapper, or nil if the wrapper resolved successfully and no chained operation has failed.";
private const string InputGetSummary = "Get returns the input with the specified name, or nil if no input matches.";
private const string InputRequiredSummary = "Required returns the input with the specified name, or an error if no input matches.";
private const string InputValueSummary = "Value returns the value of the input with the specified name, or an empty string if no input matches or it has no value.";
private const string InputRequiredValueSummary = "RequiredValue returns the value of the input with the specified name, or an error if no input matches.";
private void EmitInteractionInputCollectionAccessors(string implName)
{
// These delegate to ToArray (a single RPC) and then match locally. Names are compared
// case-insensitively via strings.EqualFold to mirror StringComparers.InteractionInputName in .NET.
WriteDocLines(RenderGoDoc("Get", InputGetSummary));
WriteLine($"func (s *{implName}) Get(name string) (*InteractionInput, error) {{");
WriteLine("\tif err := s.Err(); err != nil { return nil, err }");
WriteLine("\tinputs, err := s.ToArray()");
WriteLine("\tif err != nil { return nil, err }");
WriteLine("\tfor _, input := range inputs {");
WriteLine("\t\tif strings.EqualFold(input.Name, name) { return input, nil }");
WriteLine("\t}");
WriteLine("\treturn nil, nil");
WriteLine("}");
WriteLine();
WriteDocLines(RenderGoDoc("Required", InputRequiredSummary));
WriteLine($"func (s *{implName}) Required(name string) (*InteractionInput, error) {{");
WriteLine("\tinput, err := s.Get(name)");
WriteLine("\tif err != nil { return nil, err }");
WriteLine("\tif input == nil { return nil, fmt.Errorf(\"no input with name '%s' was found\", name) }");
WriteLine("\treturn input, nil");
WriteLine("}");
WriteLine();
WriteDocLines(RenderGoDoc("Value", InputValueSummary));
WriteLine($"func (s *{implName}) Value(name string) (string, error) {{");
WriteLine("\tinput, err := s.Get(name)");
WriteLine("\tif err != nil { return \"\", err }");
WriteLine("\tif input == nil || input.Value == nil { return \"\", nil }");
WriteLine("\treturn *input.Value, nil");
WriteLine("}");
WriteLine();
WriteDocLines(RenderGoDoc("RequiredValue", InputRequiredValueSummary));
WriteLine($"func (s *{implName}) RequiredValue(name string) (string, error) {{");
WriteLine("\tinput, err := s.Required(name)");
WriteLine("\tif err != nil { return \"\", err }");
WriteLine("\tif input.Value == nil { return \"\", nil }");
WriteLine("\treturn *input.Value, nil");
WriteLine("}");
WriteLine();
}
// ── Capability method emission ────────────────────────────────────────────
private string RenderMethodSignature(string currentTypeId, string interfaceName, AtsCapabilityInfo capability)
{
var methodName = ToPascalCase(capability.MethodName);
var targetParamName = capability.TargetParameterName ?? "builder";
var parameters = capability.Parameters
.Where(p => !string.Equals(p.Name, targetParamName, StringComparison.Ordinal))
.ToList();
var (requiredParams, optionalParams) = SplitParameters(parameters);
var paramSig = RenderParameterList(requiredParams, optionalParams, capability);
var returnSig = RenderReturnSignature(currentTypeId, interfaceName, capability);
return $"{methodName}({paramSig}){returnSig}";
}
private static (List<AtsParameterInfo> Required, List<AtsParameterInfo> Optional) SplitParameters(
List<AtsParameterInfo> parameters)
{
var required = new List<AtsParameterInfo>();
var optional = new List<AtsParameterInfo>();
foreach (var p in parameters)
{
// CancellationToken is always treated as optional regardless of
// metadata — we want app.Run() to be callable without an explicit
// token. Optional callbacks also live in Options (matches TS).
if (IsCancellationToken(p) || p.IsOptional)
{
optional.Add(p);
}
else
{
required.Add(p);
}
}
return (required, optional);
}
private string RenderParameterList(
List<AtsParameterInfo> requiredParams,
List<AtsParameterInfo> optionalParams,
AtsCapabilityInfo capability)
{
var sb = new StringBuilder();
foreach (var p in requiredParams)
{
if (sb.Length > 0)
{
sb.Append(", ");
}
sb.Append(GetLocalIdentifier(p.Name));
sb.Append(' ');
sb.Append(RenderParameterType(p));
}
if (optionalParams.Count > 0)
{
if (sb.Length > 0)
{
sb.Append(", ");
}
sb.Append("options ...");
sb.Append(GetOptionInterfaceName(capability));
}
return sb.ToString();
}
private string RenderParameterType(AtsParameterInfo p)
{
if (p.IsCallback)
{
return RenderCallbackType(p);
}
if (IsCancellationToken(p))
{
return "*CancellationToken";
}
return MapInputTypeRefToGo(p.Type, p.IsOptional);
}
/// <summary>
/// Renders a Go function type from the callback metadata. Falls back to
/// `func(...any) any` only if the metadata is empty.
/// </summary>
private string RenderCallbackType(AtsParameterInfo p)
{
var sb = new StringBuilder("func(");
if (p.CallbackParameters is { Count: > 0 } cps)
{
for (var i = 0; i < cps.Count; i++)
{
if (i > 0) { sb.Append(", "); }
sb.Append(GetLocalIdentifier(cps[i].Name));
sb.Append(' ');
sb.Append(MapTypeRefToGo(cps[i].Type, false));
}
}
else
{
sb.Append("...any");
}
sb.Append(')');
var hasReturn = p.CallbackReturnType is not null
&& p.CallbackReturnType.TypeId != AtsConstants.Void;
if (hasReturn)
{
sb.Append(' ');
sb.Append(MapTypeRefToGo(p.CallbackReturnType, false));
}
else if (p.CallbackParameters is null)
{
// Legacy fallback signature used to return any; keep that shape
// so dynamic-arg callbacks with no metadata still compile.
sb.Append(" any");
}
return sb.ToString();
}
private string RenderReturnSignature(string currentTypeId, string interfaceName, AtsCapabilityInfo capability)
{
var hasReturn = capability.ReturnType is not null && capability.ReturnType.TypeId != AtsConstants.Void;
if (!hasReturn)
{
// Void capability → returns error (sequential model). Failures are
// not deferred; the caller checks the returned error.
return " error";
}
// Handle returns yield a typed wrapper. Fluent (return == receiver)
// returns the receiver interface for chaining; the err lives on the
// receiver and is read via .Err(). Value returns are synchronous and
// return (T, error).
var returnType = capability.ReturnType!;
if (returnType.Category != AtsTypeCategory.Handle)
{
return $" ({MapTypeRefToGo(returnType, false)}, error)";
}
if (IsSameType(returnType.TypeId, capability, currentTypeId))
{
return $" {interfaceName}";
}
return $" {MapTypeRefToGo(returnType, false)}";
}
private static bool IsSameType(string? typeId, AtsCapabilityInfo capability, string? currentTypeId = null)
{
if (typeId is null)
{
return false;
}
// Fluent: return type matches the constraint type (e.g. generic `T` resolves to the constraint).
if (string.Equals(typeId, capability.TargetTypeId, StringComparison.Ordinal))
{
return true;
}
// Fluent: return type matches the specific concrete type being emitted right now.
// Do NOT check all ExpandedTargetTypes — that incorrectly flags a method as fluent
// when its return type happens to be one of the other expanded types (e.g.
// GetAzureContainerRegistry() returns AzureContainerRegistryResource which is itself
// an expanded target; testing all expanded types would mark every emit as fluent).
if (currentTypeId is not null && string.Equals(typeId, currentTypeId, StringComparison.Ordinal))
{
return true;
}
return false;
}
private void EmitCapabilityMethod(
string typeId,
string interfaceName,
string implName,
AtsCapabilityInfo capability,
List<(string Field, string MethodName, AtsTypeRef ReturnType, string CapabilityId, string? Description)> listDictGetters)
{
// List/Dict property getters get a distinct emission path.
var listDictMatch = listDictGetters.FirstOrDefault(g => g.CapabilityId == capability.CapabilityId);
if (listDictMatch.CapabilityId is not null)
{
EmitListDictGetter(implName, listDictMatch, capability);
return;
}
// Union-param capabilities are emitted as plain methods with `any`
// for the union parameters. Go's type-set generics can't express
// unions containing interfaces-with-methods (which is the shape of
// most of our handle types), so the design's generic-free-function
// form would degenerate to `func Foo[T any](...)` — pure ceremony.
// serializeValue handles dispatch by concrete type at runtime.
var methodName = ToPascalCase(capability.MethodName);
var targetParamName = capability.TargetParameterName ?? "builder";
var parameters = capability.Parameters
.Where(p => !string.Equals(p.Name, targetParamName, StringComparison.Ordinal))
.ToList();
var (requiredParams, optionalParams) = SplitParameters(parameters);
var paramSig = RenderParameterList(requiredParams, optionalParams, capability);
var returnSig = RenderReturnSignature(typeId, interfaceName, capability);
// The full documentation lives on the public interface method; the unexported implementation only
// keeps the summary so the source stays readable.
WriteDocLines(_docs.RenderSummary(CreateCapabilityDoc(capability)));
EmitUnionAllowedTypesDoc(capability);
WriteLine($"func (s *{implName}) {methodName}({paramSig}){returnSig} {{");
EmitCapabilityBody(capability, requiredParams, optionalParams, targetParamName, typeId);
WriteLine("}");
WriteLine();
}
/// <summary>
/// Emits a body for a capability that returns a handle. Sequential model:
/// fluent (return == receiver) → run RPC, store any error on s, return s.
/// Otherwise → run RPC, return a fresh child wrapper (pre-errored if the
/// parent already failed or the RPC failed).
/// </summary>
private void EmitHandleReturningBody(
AtsCapabilityInfo capability,
List<AtsParameterInfo> requiredParams,
List<AtsParameterInfo> optionalParams,
string targetParamName,
string currentTypeId)
{
var returnType = capability.ReturnType!;
var isFluent = IsSameType(returnType.TypeId, capability, currentTypeId);
var methodName = ToPascalCase(capability.MethodName);
var receiverErr = string.Equals(currentTypeId, AtsConstants.BuilderTypeId, StringComparison.Ordinal)
? "s.resourceBuilderBase.Err()"
: "s.Err()";
if (isFluent)
{
WriteLine($"\tif {receiverErr} != nil {{ return s }}");
EmitHandleParamErrorChecks("\t", capability, "s.setPropagatedErr(err); return s");
EmitUnionTypeChecks("\t", capability, methodName, new[] { "s.setErr(err); return s" });
EmitArgsConstruction("\t", capability, requiredParams, optionalParams, targetParamName, "s.handle");
WriteLine($"\tif _, err := s.client.invokeCapability(ctx, {GoStringLiteral(capability.CapabilityId)}, reqArgs); err != nil {{ s.setErr(err) }}");
WriteLine("\treturn s");
return;
}
var childImplName = returnType.TypeId is not null && _implNames.TryGetValue(returnType.TypeId, out var implTarget)
? implTarget
: null;
if (childImplName is null)
{
// No registered impl for this typeId (e.g. *ReferenceExpression,
// which is hand-written in base.go). Cast the wrapIfHandle result
// directly to the declared return type. Errors land on s; we
// return nil and the caller must consult s.Err().
var returnGoType = MapTypeRefToGo(returnType, false);
WriteLine($"\tif {receiverErr} != nil {{ return nil }}");
EmitHandleParamErrorChecks("\t", capability, "s.setPropagatedErr(err); return nil");
EmitUnionTypeChecks("\t", capability, methodName, new[] { "s.setErr(err); return nil" });
EmitArgsConstruction("\t", capability, requiredParams, optionalParams, targetParamName, "s.handle");
WriteLine($"\tresult, err := s.client.invokeCapability(ctx, {GoStringLiteral(capability.CapabilityId)}, reqArgs)");
WriteLine("\tif err != nil { s.setErr(err); return nil }");
WriteLine($"\ttyped, ok := result.({returnGoType})");
WriteLine("\tif !ok {");
WriteLine($"\t\ts.setErr(fmt.Errorf({GoUnexpectedTypeFormat(capability.CapabilityId)}, result))");
WriteLine("\t\treturn nil");
WriteLine("\t}");
WriteLine("\treturn typed");
return;
}
// Pre-errored child path: parent failure short-circuits this call.
WriteLine($"\tif err := {receiverErr}; err != nil {{ return &{childImplName}{{resourceBuilderBase: newPropagatedErroredResourceBuilder(err, s.client)}} }}");
EmitHandleParamErrorChecks("\t", capability,
$"return &{childImplName}{{resourceBuilderBase: newPropagatedErroredResourceBuilder(err, s.client)}}");
EmitUnionTypeChecks("\t", capability, methodName, new[]
{
$"return &{childImplName}{{resourceBuilderBase: newErroredResourceBuilder(err, s.client)}}",
});
EmitArgsConstruction("\t", capability, requiredParams, optionalParams, targetParamName, "s.handle");
WriteLine($"\tresult, err := s.client.invokeCapability(ctx, {GoStringLiteral(capability.CapabilityId)}, reqArgs)");
WriteLine("\tif err != nil {");
WriteLine($"\t\treturn &{childImplName}{{resourceBuilderBase: newErroredResourceBuilder(err, s.client)}}");
WriteLine("\t}");
WriteLine("\thref, ok := result.(handleReference)");
WriteLine("\tif !ok {");
WriteLine($"\t\terr := fmt.Errorf({GoUnexpectedTypeFormat(capability.CapabilityId)}, result)");
WriteLine($"\t\treturn &{childImplName}{{resourceBuilderBase: newErroredResourceBuilder(err, s.client)}}");
WriteLine("\t}");
WriteLine($"\treturn &{childImplName}{{resourceBuilderBase: newResourceBuilderBase(href.getHandle(), s.client)}}");
}
/// <summary>
/// Emits a body for a capability that returns a non-handle value. Sync RPC
/// returning (T, error). A pending error on the receiver short-circuits.
/// </summary>
private void EmitValueReturningBody(
AtsCapabilityInfo capability,
List<AtsParameterInfo> requiredParams,
List<AtsParameterInfo> optionalParams,
string targetParamName)
{
var returnType = capability.ReturnType!;
var returnGoType = MapTypeRefToGo(returnType, false);
var methodName = ToPascalCase(capability.MethodName);
WriteLine($"\tif err := s.Err(); err != nil {{ var zero {returnGoType}; return zero, err }}");
EmitHandleParamErrorChecks("\t", capability, $"var zero {returnGoType}; return zero, err");
EmitUnionTypeChecks("\t", capability, methodName, new[] { $"var zero {returnGoType}", "return zero, err" });
EmitArgsConstruction("\t", capability, requiredParams, optionalParams, targetParamName, "s.handle");
WriteLine($"\tresult, err := s.client.invokeCapability(ctx, {GoStringLiteral(capability.CapabilityId)}, reqArgs)");
WriteLine("\tif err != nil {");
WriteLine($"\t\tvar zero {returnGoType}");
WriteLine("\t\treturn zero, err");
WriteLine("\t}");
WriteLine($"\treturn decodeAs[{returnGoType}](result)");
}
/// <summary>
/// Emits a body for a void capability: synchronous RPC returning error.
/// </summary>
private void EmitVoidBody(
AtsCapabilityInfo capability,
List<AtsParameterInfo> requiredParams,
List<AtsParameterInfo> optionalParams,
string targetParamName)
{
var methodName = ToPascalCase(capability.MethodName);
WriteLine("\tif err := s.Err(); err != nil { return err }");
EmitHandleParamErrorChecks("\t", capability, "return err");
EmitUnionTypeChecks("\t", capability, methodName, new[] { "return err" });
EmitArgsConstruction("\t", capability, requiredParams, optionalParams, targetParamName, "s.handle");
WriteLine($"\t_, err := s.client.invokeCapability(ctx, {GoStringLiteral(capability.CapabilityId)}, reqArgs)");
WriteLine("\treturn err");
}
/// <summary>
/// Emits short-circuit checks for handle-typed REQUIRED parameters: if
/// any of them already carry an error, propagate it via the supplied
/// errorAction (e.g. "s.setErr(err); return s" or "return err"). Optional
/// handle parameters live inside the Options struct and are not in scope
/// at this point.
/// </summary>
private void EmitHandleParamErrorChecks(string indent, AtsCapabilityInfo capability, string errorAction)
{
var targetParamName = capability.TargetParameterName ?? "builder";
foreach (var p in capability.Parameters)
{
if (string.Equals(p.Name, targetParamName, StringComparison.Ordinal)) { continue; }
if (p.IsOptional) { continue; } // optional → in Options struct, not a local
if (p.IsCallback) { continue; }
if (IsCancellationToken(p)) { continue; }
if (p.Type?.Category != AtsTypeCategory.Handle) { continue; }
var paramName = GetLocalIdentifier(p.Name);
WriteLine($"{indent}if {paramName} != nil {{ if err := {paramName}.Err(); err != nil {{ {errorAction} }} }}");
}
}
private void EmitArgsConstruction(
string indent,
AtsCapabilityInfo capability,
List<AtsParameterInfo> requiredParams,
List<AtsParameterInfo> optionalParams,
string targetParamName,
string targetHandleVar)
{
// ctx defaults to Background. If the caller provided a CancellationToken
// via Options, we override ctx inside the options block below so the
// local Go RPC respects the user's cancellation.
WriteLine($"{indent}ctx := context.Background()");
WriteLine($"{indent}reqArgs := map[string]any{{");
WriteLine($"{indent}\t{GoStringLiteral(targetParamName)}: {targetHandleVar}.ToJSON(),");
WriteLine($"{indent}}}");
foreach (var p in requiredParams)
{
var paramName = GetLocalIdentifier(p.Name);
if (p.IsCallback)
{
EmitCallbackRegistration(indent, p, paramName);
continue;
}
if (IsCancellationToken(p))
{
// Only register/send the cancellation token when the caller
// explicitly provides one — matches TypeScript SDK behavior.
// We deliberately do NOT substitute a default token here:
// tying every server-side op to a wrapper-internal lifetime
// would create unwanted cross-cancellation and leak token
// state on the server for ops the user didn't ask to cancel.
WriteLine($"{indent}if {paramName} != nil {{");
WriteLine($"{indent}\tctx = {paramName}.Context()");
WriteLine($"{indent}\tif id := s.client.registerCancellation({paramName}); id != \"\" {{");
WriteLine($"{indent}\t\treqArgs[{GoStringLiteral(p.Name)}] = id");
WriteLine($"{indent}\t}}");
WriteLine($"{indent}}}");
continue;
}
var type = MapTypeRefToGo(p.Type, isOptional: false);
// A nil DTO or ReferenceExpression must be checked before it is boxed into any; otherwise
// serializeValue sees a non-nil interface and dereferences the typed nil. Nullable primitive
// and enum pointers intentionally bypass this guard because nil represents an explicit ATS null.
if (IsNilableGoType(type) && (p.Type is null || !ShouldApplyNullableType(p.Type)))
{
var nilGuard = type == "any" ? $"!isNil({paramName})" : $"{paramName} != nil";
WriteLine($"{indent}if {nilGuard} {{ reqArgs[{GoStringLiteral(p.Name)}] = serializeValue({paramName}) }}");
}
else
{
WriteLine($"{indent}reqArgs[{GoStringLiteral(p.Name)}] = serializeValue({paramName})");
}
}
if (optionalParams.Count > 0)
{
var optionsType = GetOptionsTypeName(capability);
WriteLine($"{indent}if len(options) > 0 {{");
WriteLine($"{indent}\tmerged := &{optionsType}{{}}");
WriteLine($"{indent}\tfor _, opt := range options {{");
WriteLine($"{indent}\t\tif opt != nil {{ opt.apply(merged) }}");
WriteLine($"{indent}\t}}");
WriteLine($"{indent}\tfor k, v := range merged.ToMap() {{ reqArgs[k] = v }}");
// Callbacks: wrap the typed user function in a positional `any`
// shim that the transport's invokeCallback can call.
foreach (var p in optionalParams.Where(o => o.IsCallback))
{
var fieldName = ToPascalCase(p.Name);
EmitCallbackRegistration(indent + "\t", p, $"merged.{fieldName}");
}
// Cancellation tokens: register, inject the id, and use the
// token's context for local Go-side cancellation.
foreach (var p in optionalParams.Where(IsCancellationToken))
{
var fieldName = ToPascalCase(p.Name);
WriteLine($"{indent}\tif merged.{fieldName} != nil {{");
WriteLine($"{indent}\t\tctx = merged.{fieldName}.Context()");
WriteLine($"{indent}\t\tif id := s.client.registerCancellation(merged.{fieldName}); id != \"\" {{");
WriteLine($"{indent}\t\t\treqArgs[{GoStringLiteral(p.Name)}] = id");
WriteLine($"{indent}\t\t}}");
WriteLine($"{indent}\t}}");
}
WriteLine($"{indent}}}");
}
}
/// <summary>
/// Emits a callback-registration block for the supplied callback variable.
/// Generates a typed→positional shim that decodes positional args back to
/// the user-supplied callback's parameter types.
/// </summary>
private void EmitCallbackRegistration(string indent, AtsParameterInfo p, string callbackExpr)
{
WriteLine($"{indent}if {callbackExpr} != nil {{");
WriteLine($"{indent}\tcb := {callbackExpr}");
WriteLine($"{indent}\tshim := func(args ...any) any {{");
EmitGoCallbackShimBody($"{indent}\t\t", p, "s.client");
WriteLine($"{indent}\t}}");
WriteLine($"{indent}\treqArgs[{GoStringLiteral(p.Name)}] = s.client.registerCallback(shim)");
WriteLine($"{indent}}}");
}
// Emits a strongly-typed DTO callback property into the DTO's ToMap output. Method parameters
// pre-register their callback (putting a string id into reqArgs), but DTO properties instead
// embed the shim func directly in the map; the client's marshalTransportValue walks the
// serialized args, finds the func(...any) any value, and registers it. This keeps the public
// DTO field strongly typed (e.g. func(ctx InputsDialogValidationContext)) while reusing the
// exact arg-decoding/return/writeback behavior of method-parameter callbacks.
private void EmitDtoCallbackToMap(AtsDtoPropertyInfo property, string propertyName)
{
var p = DtoPropertyToParameterInfo(property);
WriteLine($"\tif d.{propertyName} != nil {{");
WriteLine($"\t\tcb := d.{propertyName}");
WriteLine($"\t\tm[{GoStringLiteral(JsonNamingPolicy.CamelCase.ConvertName(property.Name))}] = func(args ...any) any {{");
EmitGoCallbackShimBody("\t\t\t", p, clientExpression: null);
WriteLine($"\t\t}}");
WriteLine($"\t}}");
}
private static AtsParameterInfo DtoPropertyToParameterInfo(AtsDtoPropertyInfo property)
=> new()
{
Name = property.Name,
Type = property.Type,
IsOptional = property.IsOptional,
IsCallback = property.IsCallback,
CallbackParameters = property.CallbackParameters,
CallbackReturnType = property.CallbackReturnType,
};
// Writes the body of a `func(args ...any) any` callback shim, assuming the user's typed
// callback is in scope as `cb`. Shared by method-parameter and DTO-property callbacks.
// clientExpression is the Go expression for the *client when one is in scope; DTO-property
// shims run from ToMap, which has none, so they pass null.
private void EmitGoCallbackShimBody(string indent, AtsParameterInfo p, string? clientExpression)
{
var hasReturn = p.CallbackReturnType is not null
&& p.CallbackReturnType.TypeId != AtsConstants.Void;
var callExpr = new StringBuilder();
callExpr.Append("cb(");
if (p.CallbackParameters is { Count: > 0 } cps)
{
for (var i = 0; i < cps.Count; i++)
{
if (i > 0) { callExpr.Append(", "); }
callExpr.Append(GetCallbackArgExpression(cps[i].Type, i, clientExpression));
}
}
else
{
callExpr.Append("args...");
}
callExpr.Append(')');
if (hasReturn)
{
WriteLine($"{indent}return {callExpr}");
}
else if (p.CallbackParameters is null)
{
// Legacy untyped callback returning any — preserve return value.
WriteLine($"{indent}return {callExpr}");
}
else if (p.CallbackParameters is { Count: > 0 } callbackParameters && callbackParameters.Any(cp => cp.Type.Category == AtsTypeCategory.Dto))
{
var argNames = new List<string>(callbackParameters.Count);
for (var i = 0; i < callbackParameters.Count; i++)
{
var argName = $"arg{i}";
argNames.Add(argName);
WriteLine($"{indent}{argName} := {GetCallbackArgExpression(callbackParameters[i].Type, i, clientExpression)}");
}
WriteLine($"{indent}cb({string.Join(", ", argNames)})");
WriteLine($"{indent}return map[string]any{{");
for (var i = 0; i < callbackParameters.Count; i++)
{
if (callbackParameters[i].Type.Category == AtsTypeCategory.Dto)
{
WriteLine($"{indent}\t\"p{i}\": serializeValue({argNames[i]}),");
}
}
WriteLine($"{indent}}}");
}
else
{
WriteLine($"{indent}{callExpr}");
WriteLine($"{indent}return nil");
}
}
// Returns the Go expression that reads callback argument `index` as the user's typed parameter.
// The host tags a handle argument with the callback's declared CLR parameter type. For
// IResourceBuilder<T> that is a closed generic type id that has no registered wrapper, so the
// transport delivers a raw *handle. Building the declared wrapper from it, as the TypeScript
// SDK does, keeps the user's callback from receiving nil. Marker interfaces have no generated
// constructor, but registerWrappers registers a wrapper for their type id, so they keep
// callbackArg, as do shims with no client in scope.
private string GetCallbackArgExpression(AtsTypeRef type, int index, string? clientExpression)
{
if (clientExpression is not null && IsCancellationTokenTypeId(type.TypeId))
{
return $"callbackCancellationTokenArg({clientExpression}, args, {index})";
}
if (clientExpression is not null
&& type.Category == AtsTypeCategory.Handle
&& _implNames.ContainsKey(type.TypeId))
{
return $"callbackHandleArg({clientExpression}, args, {index}, new{_interfaceNames[type.TypeId]}FromHandle)";
}
return $"callbackArg[{MapTypeRefToGo(type, false)}](args, {index})";
}
// ── List / Dict accessor methods ─────────────────────────────────────────
private void EmitListDictGetter(
string implName,
(string Field, string MethodName, AtsTypeRef ReturnType, string CapabilityId, string? Description) g,
AtsCapabilityInfo capability)
{
var (wrapperType, typeArgs) = ListDictTypeArgs(g.ReturnType);
var factory = wrapperType == "Dict" ? "newDictWithGetter" : "newListWithGetter";
WriteDocLines(_docs.RenderSummary(CreateCapabilityDoc(capability)));
WriteLine($"func (s *{implName}) {g.MethodName}() *{wrapperType}{typeArgs} {{");
WriteLine("\ts.collectionMu.Lock()");
WriteLine("\tdefer s.collectionMu.Unlock()");
WriteLine($"\tif s.{g.Field} == nil {{");
WriteLine($"\t\ts.{g.Field} = {factory}{typeArgs}(s.handleWrapperBase, {GoStringLiteral(g.CapabilityId)})");
WriteLine("\t}");
WriteLine($"\treturn s.{g.Field}");
WriteLine("}");
WriteLine();
}
private (string Wrapper, string TypeArgs) ListDictTypeArgs(AtsTypeRef returnType)
{
if (returnType.Category == AtsTypeCategory.Dict)
{
var keyType = MapTypeRefToGo(returnType.KeyType, false);
var valueType = MapTypeRefToGo(returnType.ValueType, false);
return ("Dict", $"[{keyType}, {valueType}]");
}
var elementType = MapTypeRefToGo(returnType.ElementType, false);
return ("List", $"[{elementType}]");
}
// ── Options structs ──────────────────────────────────────────────────────
private void GenerateOptionsStructs(IReadOnlyList<AtsCapabilityInfo> capabilities)
{
var emitted = new HashSet<string>(StringComparer.Ordinal);
var seenForCapability = new Dictionary<string, string>(StringComparer.Ordinal);
var anyEmitted = false;
foreach (var capability in capabilities)
{
var targetParamName = capability.TargetParameterName ?? "builder";
var optionalParams = capability.Parameters
.Where(p => !string.Equals(p.Name, targetParamName, StringComparison.Ordinal))
.Where(p => IsCancellationToken(p) || p.IsOptional)
.ToList();
if (optionalParams.Count == 0)
{
continue;
}
var name = GetOptionsTypeName(capability);
seenForCapability[capability.CapabilityId] = name;
if (!emitted.Add(name))
{
continue;
}
if (!anyEmitted)
{
WriteLine("// ============================================================================");
WriteLine("// Options structs");
WriteLine("// ============================================================================");
WriteLine();
anyEmitted = true;
}
WriteLine($"// {name} carries optional parameters for {ToPascalCase(capability.MethodName)}.");
WriteLine($"type {name} struct {{");
foreach (var p in optionalParams)
{
var fieldName = ToPascalCase(p.Name);
var fieldType = RenderOptionsFieldType(p);
// Callback fields and cancellation tokens are not JSON-marshaled
// — they're handled via registerCallback / registerCancellation
// in the body. Marking with json:"-" keeps them out of any
// accidental Marshal calls.
var jsonTag = (p.IsCallback || IsCancellationToken(p))
? "`json:\"-\"`"
: $"`json:{GoStringLiteral(p.Name + ",omitempty")}`";
WriteDocLines(_docs.Render(new GoDocContent
{
GoName = fieldName,
Subject = GoDocSubject.Field,
Summary = capability.CapabilityKind == AtsCapabilityKind.PropertySetter ? null : p.Documentation?.Summary,
}), "\t");
WriteLine($"\t{fieldName} {fieldType} {jsonTag}");
}
WriteLine("}");
WriteLine();
var optionInterfaceName = GetOptionInterfaceName(capability);
WriteLine($"// {optionInterfaceName} configures {ToPascalCase(capability.MethodName)}.");
WriteLine($"type {optionInterfaceName} interface {{");
WriteLine($"\tapply(*{name})");
WriteLine("}");
WriteLine();
WriteLine($"func (option *{name}) apply(options *{name}) {{");
WriteLine("\tif option != nil { *options = *deepUpdate(options, option) }");
WriteLine("}");
WriteLine();
foreach (var p in optionalParams)
{
var fieldName = ToPascalCase(p.Name);
var optionName = GetOptionFunctionName(capability, p);
var parameterType = RenderOptionFunctionParameterType(p);
WriteLine($"// {optionName} sets {fieldName} for {ToPascalCase(capability.MethodName)}.");
if (CanUseDirectOptionType(p))
{
WriteLine($"type {optionName} {parameterType}");
WriteLine();
WriteLine($"func (value {optionName}) apply(options *{name}) {{");
WriteLine($"\toptions.{fieldName} = {RenderDirectOptionAssignment(p)}");
WriteLine("}");
}
else
{
var implementationName = ToCamelCase(optionName);
WriteLine($"type {implementationName} struct {{ value {parameterType} }}");
WriteLine();
WriteLine($"func (option {implementationName}) apply(options *{name}) {{");
WriteLine($"\toptions.{fieldName} = {RenderWrappedOptionAssignment(p)}");
WriteLine("}");
WriteLine();
WriteLine($"func {optionName}(value {parameterType}) {optionInterfaceName} {{");
WriteLine($"\treturn {implementationName}{{value: value}}");
WriteLine("}");
}
WriteLine();
}
WriteLine($"func (o *{name}) ToMap() map[string]any {{");
WriteLine("\tm := map[string]any{}");
WriteLine("\tif o == nil { return m }");
foreach (var p in optionalParams)
{
// Callbacks and CTs are registered separately by the calling
// body — they cannot be plain-serialized.
if (p.IsCallback || IsCancellationToken(p))
{
continue;
}
var fieldName = ToPascalCase(p.Name);
var fieldType = MapTypeRefToGo(p.Type, isOptional: true);
if (IsNilableGoType(fieldType))
{
var value = p.Type?.Category == AtsTypeCategory.Handle
&& !IsNilableGoType(MapInputTypeRefToGo(p.Type, isOptional: false))
? $"*o.{fieldName}"
: $"o.{fieldName}";
WriteLine($"\tif o.{fieldName} != nil {{ m[{GoStringLiteral(p.Name)}] = serializeValue({value}) }}");
}
else
{
WriteLine($"\tm[{GoStringLiteral(p.Name)}] = serializeValue(o.{fieldName})");
}
}
WriteLine("\treturn m");
WriteLine("}");
WriteLine();
}
}
private void GenerateCreateBuilderOptions(AtsContext context)
{
if (_createBuilderOptionInterfaceName is null
|| context.DtoTypes.FirstOrDefault(dto => dto.Name == "CreateBuilderOptions") is not { } dto
|| !_dtoNames.TryGetValue(dto.TypeId, out var optionsType))
{
return;
}
WriteLine($"// {_createBuilderOptionInterfaceName} configures CreateBuilder.");
WriteLine($"type {_createBuilderOptionInterfaceName} interface {{");
WriteLine($"\tapply(*{optionsType})");
WriteLine("}");
WriteLine();
WriteLine($"func (option *{optionsType}) apply(options *{optionsType}) {{");
WriteLine("\tif option != nil { *options = *deepUpdate(options, option) }");
WriteLine("}");
WriteLine();
foreach (var property in dto.Properties)
{
var fieldName = ToPascalCase(property.Name);
var optionName = _createBuilderOptionValueNames[property.Name];
var valueType = RenderCreateBuilderOptionValueType(property);
var fieldType = MapDtoPropertyTypeToGo(property.Type, property.IsOptional);
WriteLine($"// {optionName} sets {fieldName} for CreateBuilder.");
if (CanUseDirectOptionType(property.Type))
{
WriteLine($"type {optionName} {valueType}");
WriteLine();
WriteLine($"func (value {optionName}) apply(options *{optionsType}) {{");
var assignment = fieldType == $"*{valueType}"
? $"(*{valueType})(&value)"
: $"({fieldType})(value)";
WriteLine($"\toptions.{fieldName} = {assignment}");
WriteLine("}");
}
else
{
var implementationName = ToCamelCase(optionName);
WriteLine($"type {implementationName} struct {{ value {valueType} }}");
WriteLine();
WriteLine($"func (option {implementationName}) apply(options *{optionsType}) {{");
WriteLine($"\toptions.{fieldName} = option.value");
WriteLine("}");
WriteLine();
WriteLine($"func {optionName}(value {valueType}) {_createBuilderOptionInterfaceName} {{");
WriteLine($"\treturn {implementationName}{{value: value}}");
WriteLine("}");
}
WriteLine();
}
}
private string RenderCreateBuilderOptionValueType(AtsDtoPropertyInfo property)
{
var type = MapDtoPropertyTypeToGo(property.Type, isOptional: false);
return property.Type.Category is AtsTypeCategory.Primitive or AtsTypeCategory.Enum
&& type.StartsWith("*", StringComparison.Ordinal)
? type[1..]
: type;
}
private static bool CanUseDirectOptionType(AtsTypeRef type)
{
return type.Category switch
{
AtsTypeCategory.Primitive => type.TypeId is not (AtsConstants.Any or AtsConstants.ReferenceExpressionTypeId),
AtsTypeCategory.Enum or AtsTypeCategory.Callback or AtsTypeCategory.Array => true,
AtsTypeCategory.List or AtsTypeCategory.Dict => type.IsReadOnly,
_ => false
};
}
/// <summary>
/// Field type for an Options struct member. Callbacks render as their
/// typed Go function signature; CT renders as *CancellationToken; other
/// values use the input mapping with optional/nilable wrapping.
/// </summary>
private string RenderOptionsFieldType(AtsParameterInfo p)
{
if (p.IsCallback)
{
return RenderCallbackType(p);
}
if (IsCancellationToken(p))
{
return "*CancellationToken";
}
return MapInputTypeRefToGo(p.Type, isOptional: true);
}
private string GetOptionsTypeName(AtsCapabilityInfo capability)
{
return _optionsTypeNames.TryGetValue(capability.CapabilityId, out var name)
? name
: $"{ToPascalCase(capability.MethodName)}Options";
}
private string GetOptionInterfaceName(AtsCapabilityInfo capability)
{
return _optionInterfaceNames.TryGetValue(capability.CapabilityId, out var name)
? name
: $"{ToPascalCase(capability.MethodName)}Option";
}
private string GetOptionFunctionName(AtsCapabilityInfo capability, AtsParameterInfo parameter)
{
return _optionValueNames.TryGetValue((capability.CapabilityId, parameter.Name), out var name)
? name
: GetOptionValueNameCandidate(GetOptionsTypeName(capability), ToPascalCase(parameter.Name));
}
private static string GetOptionValueNameCandidate(string optionsTypeName, string fieldName)
{
var prefix = optionsTypeName;
if (prefix.EndsWith("Options", StringComparison.Ordinal))
{
prefix = prefix[..^"Options".Length];
}
if (prefix.StartsWith("With", StringComparison.Ordinal)
&& prefix.Length > "With".Length
&& char.IsUpper(prefix["With".Length]))
{
prefix = prefix["With".Length..];
}
return prefix + fieldName;
}
private string RenderOptionFunctionParameterType(AtsParameterInfo parameter)
{
if (parameter.IsCallback)
{
return RenderCallbackType(parameter);
}
if (IsCancellationToken(parameter))
{
return "*CancellationToken";
}
var type = MapInputTypeRefToGo(parameter.Type, isOptional: false);
return parameter.Type is { Category: AtsTypeCategory.Primitive or AtsTypeCategory.Enum }
&& type.StartsWith("*", StringComparison.Ordinal)
? type[1..]
: type;
}
private static bool CanUseDirectOptionType(AtsParameterInfo parameter)
{
if (IsCancellationToken(parameter) || parameter.Type is not { } type)
{
return false;
}
return CanUseDirectOptionType(type);
}
private string RenderDirectOptionAssignment(AtsParameterInfo parameter)
{
var fieldType = RenderOptionsFieldType(parameter);
var parameterType = RenderOptionFunctionParameterType(parameter);
if (fieldType == $"*{parameterType}")
{
return $"(*{parameterType})(&value)";
}
return $"({fieldType})(value)";
}
private string RenderWrappedOptionAssignment(AtsParameterInfo parameter)
{
return RenderOptionsFieldType(parameter) == RenderOptionFunctionParameterType(parameter)
? "option.value"
: "&option.value";
}
private string GetOptionsQualifiedName(AtsCapabilityInfo capability, string simpleName)
{
var targetInterfaceName = capability.TargetTypeId is not null
&& _interfaceNames.TryGetValue(capability.TargetTypeId, out var ifName)
? ifName
: SanitizeIdentifier(capability.TargetTypeId ?? "Unknown");
return $"{targetInterfaceName}{simpleName}";
}
/// <summary>
/// Returns true when two capabilities have the same optional-param field
/// signatures and can share a single Options struct. Compares params by
/// name and rendered Go type so callbacks with different argument types
/// (e.g. AzureStorageEmulatorResource vs AzureEventHubsEmulatorResource)
/// are correctly detected as incompatible.
/// </summary>
private bool AreOptionsCompatible(AtsCapabilityInfo a, AtsCapabilityInfo b)
{
var aTarget = a.TargetParameterName ?? "builder";
var bTarget = b.TargetParameterName ?? "builder";
var aOpts = a.Parameters
.Where(p => !string.Equals(p.Name, aTarget, StringComparison.Ordinal)
&& (IsCancellationToken(p) || p.IsOptional))
.OrderBy(p => p.Name, StringComparer.Ordinal)
.ToList();
var bOpts = b.Parameters
.Where(p => !string.Equals(p.Name, bTarget, StringComparison.Ordinal)
&& (IsCancellationToken(p) || p.IsOptional))
.OrderBy(p => p.Name, StringComparer.Ordinal)
.ToList();
if (aOpts.Count != bOpts.Count)
{
return false;
}
for (var i = 0; i < aOpts.Count; i++)
{
if (!string.Equals(aOpts[i].Name, bOpts[i].Name, StringComparison.Ordinal))
{
return false;
}
if (!string.Equals(RenderOptionsFieldType(aOpts[i]), RenderOptionsFieldType(bOpts[i]), StringComparison.Ordinal))
{
return false;
}
}
return true;
}
// ── Union parameter handling ─────────────────────────────────────────────
//
// Go's type-set generics cannot express unions containing
// interfaces-with-methods, which is the shape of nearly every metadata
// union (handle types are always interfaces-with-methods). The
// design-spec generic-free-function form would degenerate to
// `func Foo[T any](...)` — pure ceremony with no compile-time benefit.
//
// Instead union parameters are emitted as plain `any` and the generated
// body type-checks them at runtime against the documented allowed-types
// list. Wrong types fail fast with a clear error rather than silently
// serialising garbage. The allowed types are also enumerated in the
// method's doc comment so editors / godoc surface them.
private static List<AtsParameterInfo> GetUnionParameters(AtsCapabilityInfo capability)
{
// Required positional union parameters only — optional unions live in
// the Options struct as `any` fields and aren't bound to a local var.
var targetParamName = capability.TargetParameterName ?? "builder";
var result = new List<AtsParameterInfo>();
foreach (var p in capability.Parameters)
{
if (string.Equals(p.Name, targetParamName, StringComparison.Ordinal)) { continue; }
if (p.IsOptional) { continue; }
if (p.IsCallback) { continue; }
if (IsCancellationToken(p)) { continue; }
if (p.Type?.Category == AtsTypeCategory.Union)
{
result.Add(p);
}
}
return result;
}
/// <summary>
/// Emits a runtime type guard for each union parameter. On mismatch the
/// emitted code creates a descriptive error and runs the supplied
/// errorAction lines (e.g. propagating to a child wrapper) before
/// returning. Caller is responsible for choosing the right indentation
/// and the right error action for the surrounding shape (sync vs goroutine).
/// </summary>
private void EmitUnionTypeChecks(
string indent,
AtsCapabilityInfo capability,
string methodName,
IReadOnlyList<string> errorActionLines)
{
foreach (var p in GetUnionParameters(capability))
{
var paramName = GetLocalIdentifier(p.Name);
var allowed = GetUnionAllowedTypes(p.Type!);
var acceptsNumbers = AllowsGoNumericTypes(GetUnionMemberTypes(p.Type!));
WriteLine(acceptsNumbers
? $"{indent}switch value := {paramName}.(type) {{"
: $"{indent}switch {paramName}.(type) {{");
if (acceptsNumbers)
{
WriteLine($"{indent}case json.Number:");
WriteLine($"{indent}\tif !isValidJSONNumber(value) {{");
WriteLine($"{indent}\t\terr := fmt.Errorf(\"aspire: {methodName}: parameter %q must be one of [{allowed}], got invalid json.Number %q\", {GoStringLiteral(p.Name)}, value)");
foreach (var line in errorActionLines)
{
WriteLine($"{indent}\t\t{line}");
}
WriteLine($"{indent}\t}}");
}
WriteLine($"{indent}case {GetUnionTypeSwitchCases(p.Type!)}:");
WriteLine($"{indent}default:");
WriteLine($"{indent}\terr := fmt.Errorf(\"aspire: {methodName}: parameter %q must be one of [{allowed}], got %T\", {GoStringLiteral(p.Name)}, {paramName})");
foreach (var line in errorActionLines)
{
WriteLine($"{indent}\t{line}");
}
WriteLine($"{indent}}}");
}
}
private void EmitUnionAllowedTypesDoc(AtsCapabilityInfo capability)
{
foreach (var note in GetUnionNotes(capability))
{
WriteLine($"// {note}");
}
}
// The notes feed both the unexported implementation method's comment (EmitUnionAllowedTypesDoc)
// and the exported method's godoc (CreateCapabilityDoc), so the two always say the same thing.
private List<string> GetUnionNotes(AtsCapabilityInfo capability)
{
return GetUnionParameters(capability)
.Select(p =>
{
var allowed = GetUnionAllowedTypes(p.Type!);
var numericNote = AllowsGoNumericTypes(GetUnionMemberTypes(p.Type!))
? " (float64 accepts any built-in Go numeric type and json.Number)"
: "";
return $"Allowed types for parameter {GetLocalIdentifier(p.Name)}: {allowed}{numericNote}.";
})
.ToList();
}
private string GetUnionAllowedTypes(AtsTypeRef typeRef) => string.Join(", ", GetUnionMemberTypes(typeRef));
// float64 is how an ATS number maps to Go. A union that contains it also accepts the other
// Go numeric types (see GetUnionTypeSwitchCases), and the generated godoc says so.
private static bool AllowsGoNumericTypes(string[] memberTypes) => memberTypes.Contains("float64", StringComparer.Ordinal);
// Go boxes an untyped constant such as 3 as int, so a union that allows an ATS number
// (float64) has to accept the other Go numeric types too. They all serialize as JSON numbers.
private string GetUnionTypeSwitchCases(AtsTypeRef typeRef)
{
var memberTypes = GetUnionMemberTypes(typeRef);
if (!AllowsGoNumericTypes(memberTypes))
{
return string.Join(", ", memberTypes);
}
return string.Join(", ", memberTypes.Concat(s_goNumericTypes).Distinct(StringComparer.Ordinal));
}
private string[] GetUnionMemberTypes(AtsTypeRef typeRef)
{
if (typeRef.UnionTypes is not { Count: > 0 } types)
{
return ["any"];
}
var allowedTypes = types
.Select(type => MapInputTypeRefToGo(type, isOptional: false))
.Distinct(StringComparer.Ordinal)
.ToArray();
return allowedTypes.Length > 0 ? allowedTypes : ["any"];
}
/// <summary>
/// Routes to the appropriate body emission based on return shape.
/// </summary>
private void EmitCapabilityBody(
AtsCapabilityInfo capability,
List<AtsParameterInfo> requiredParams,
List<AtsParameterInfo> optionalParams,
string targetParamName,
string currentTypeId)
{
var hasReturn = capability.ReturnType is not null && capability.ReturnType.TypeId != AtsConstants.Void;
if (hasReturn && capability.ReturnType!.Category == AtsTypeCategory.Handle)
{
EmitHandleReturningBody(capability, requiredParams, optionalParams, targetParamName, currentTypeId);
}
else if (hasReturn)
{
EmitValueReturningBody(capability, requiredParams, optionalParams, targetParamName);
}
else
{
EmitVoidBody(capability, requiredParams, optionalParams, targetParamName);
}
}
// ── Per-client wrapper registration ──────────────────────────────────────
private void GenerateRegisterWrappers()
{
WriteLine("// ============================================================================");
WriteLine("// Per-client handle wrapper registration");
WriteLine("// ============================================================================");
WriteLine();
WriteLine("func registerWrappers(c *client) {");
WriteLine("\tc.registerHandleWrapper(\"" + AtsConstants.ReferenceExpressionTypeId + "\", func(h *handle, c *client) any {");
WriteLine("\t\treturn newHandleBackedReferenceExpression(h, c)");
WriteLine("\t})");
foreach (var (typeId, implName) in _implNames.OrderBy(kv => kv.Value, StringComparer.Ordinal))
{
var interfaceName = _interfaceNames[typeId];
WriteLine($"\tc.registerHandleWrapper({GoStringLiteral(typeId)}, func(h *handle, c *client) any {{");
WriteLine($"\t\treturn new{interfaceName}FromHandle(h, c)");
WriteLine("\t})");
}
// The AppHost tags a handle with the type its capability declares, so a result such as
// IResource[] arrives with the IResource type id. A marker interface has no generated
// constructor, and without a registered wrapper wrapIfHandle leaves a raw *handle that no
// marker interface accepts. The bare wrapper satisfies every marker, as the TypeScript
// SDK's registration of its interface wrappers does.
foreach (var (typeId, _) in GetMarkerInterfaces())
{
WriteLine($"\tc.registerHandleWrapper(\"{typeId}\", func(h *handle, c *client) any {{");
WriteLine("\t\treturn newHandleWrapperBase(h, c)");
WriteLine("\t})");
}
WriteLine("}");
WriteLine();
}
// ── CreateBuilder / Build ────────────────────────────────────────────────
private void GenerateCreateBuilder(AtsContext context)
{
var builderTypeId = AtsConstants.BuilderTypeId;
var hasBuilderImpl = _implNames.TryGetValue(builderTypeId, out var existingBuilderImpl);
var builderInterface = _interfaceNames.TryGetValue(builderTypeId, out var existingBuilderIface)
? existingBuilderIface
: "DistributedApplicationBuilder";
var builderImpl = hasBuilderImpl ? existingBuilderImpl! : ToCamelCase(builderInterface);
var appTypeId = AtsConstants.ApplicationTypeId;
var hasAppInterface = _interfaceNames.ContainsKey(appTypeId);
var appInterface = hasAppInterface ? _interfaceNames[appTypeId] : "DistributedApplication";
var hasAppImpl = _implNames.ContainsKey(appTypeId);
WriteLine("// ============================================================================");
WriteLine("// Builder construction & Build()");
WriteLine("// ============================================================================");
WriteLine();
if (!hasBuilderImpl)
{
// Synthesize a private impl struct. The interface (and any
// capability methods on it) was emitted by GenerateConcreteHandleTypes
// already if hasBuilderImpl was true; otherwise we synthesize a
// minimal interface here.
if (!_interfaceNames.ContainsKey(builderTypeId))
{
WriteLine($"// {builderInterface} is the entry point to the Aspire SDK.");
WriteLine($"type {builderInterface} interface {{");
WriteLine("\thandleReference");
WriteLine($"\tBuild() ({appInterface}, error)");
WriteLine("}");
WriteLine();
}
WriteLine($"type {builderImpl} struct {{ *resourceBuilderBase }}");
WriteLine();
}
if (!hasAppInterface)
{
WriteLine($"// {appInterface} is returned by Build(); it represents the running application.");
WriteLine($"type {appInterface} interface {{ handleReference }}");
WriteLine();
}
if (!hasAppImpl)
{
// Synthesize an impl so wrapIfHandle can return a value satisfying
// the interface even when the metadata doesn't expose the application
// as a concrete handle type.
WriteLine($"type {ToCamelCase(appInterface)} struct {{ *resourceBuilderBase }}");
WriteLine();
}
// Err overrides the promoted handle Err(): a failed AddX records its error on
// the child it returns, which a caller may never check, so the builder
// reports every error owned by a handle of its client.
WriteDocLines(RenderGoDoc("Err", BuilderErrSummary));
WriteLine($"func (b *{builderImpl}) Err() error {{");
WriteLine("\tif err := b.client.combinedError(); err != nil { return err }");
WriteLine("\treturn b.resourceBuilderBase.Err()");
WriteLine("}");
WriteLine();
// Build() method on the concrete impl. Sequential model: any prior
// chain error, including one recorded only on a child handle,
// short-circuits; otherwise invoke the build capability and return the
// wrapped result.
WriteDocLines(RenderGoDoc("Build", BuildSummary));
WriteLine($"func (b *{builderImpl}) Build() ({appInterface}, error) {{");
WriteLine("\tif err := b.Err(); err != nil { return nil, err }");
WriteLine($"\tresult, err := b.client.invokeCapability(context.Background(), \"{AtsConstants.BuildCapability}\", map[string]any{{");
WriteLine("\t\t\"context\": b.handle.ToJSON(),");
WriteLine("\t})");
WriteLine("\tif err != nil { return nil, err }");
WriteLine($"\tapp, ok := result.({appInterface})");
WriteLine($"\tif !ok {{ return nil, fmt.Errorf(\"aspire: build returned unexpected type %T\", result) }}");
WriteLine("\treturn app, nil");
WriteLine("}");
WriteLine();
// CreateBuilder factory.
var createOptionsType = GetCreateBuilderOptionsType(context);
WriteLine($"// CreateBuilder establishes a connection to the AppHost and returns a new builder.");
if (createOptionsType is not null && _createBuilderOptionInterfaceName is not null)
{
WriteLine($"func CreateBuilder(options ...{_createBuilderOptionInterfaceName}) ({builderInterface}, error) {{");
}
else
{
WriteLine($"func CreateBuilder() ({builderInterface}, error) {{");
}
WriteLine("\tsocketPath := os.Getenv(\"REMOTE_APP_HOST_SOCKET_PATH\")");
WriteLine("\tif socketPath == \"\" {");
WriteLine("\t\treturn nil, fmt.Errorf(\"REMOTE_APP_HOST_SOCKET_PATH environment variable not set. Run this application using `aspire run`\")");
WriteLine("\t}");
WriteLine("\tc := newClient(socketPath)");
WriteLine("\tif err := c.connect(context.Background(), 5*time.Second); err != nil { return nil, err }");
WriteLine("\tc.onDisconnect(func() { os.Exit(1) })");
WriteLine("\tregisterWrappers(c)");
WriteLine();
WriteLine("\tresolved := map[string]any{}");
if (createOptionsType is not null && _createBuilderOptionInterfaceName is not null)
{
WriteLine("\tif len(options) > 0 {");
WriteLine($"\t\tmerged := &{createOptionsType}{{}}");
WriteLine("\t\tfor _, opt := range options {");
WriteLine("\t\t\tif opt != nil { opt.apply(merged) }");
WriteLine("\t\t}");
WriteLine("\t\tfor k, v := range merged.ToMap() { resolved[k] = v }");
WriteLine("\t}");
}
WriteLine("\tif _, ok := resolved[\"args\"]; !ok { resolved[\"args\"] = os.Args[1:] }");
// ASPIRE_PROJECT_DIRECTORY is set by the CLI so the host reports the correct project
// directory (not the cwd) when matching --apphost <directory> requests.
WriteLine("\tif projectDirectory, ok := resolved[\"projectDirectory\"].(string); !ok || projectDirectory == \"\" {");
WriteLine("\t\tif projectDirectory := os.Getenv(\"ASPIRE_PROJECT_DIRECTORY\"); projectDirectory != \"\" {");
WriteLine("\t\t\tresolved[\"projectDirectory\"] = projectDirectory");
WriteLine("\t\t} else if pwd, err := os.Getwd(); err == nil {");
WriteLine("\t\t\tresolved[\"projectDirectory\"] = pwd");
WriteLine("\t\t}");
WriteLine("\t}");
WriteLine("\tif appHostFilePath, ok := resolved[\"appHostFilePath\"].(string); !ok || appHostFilePath == \"\" {");
WriteLine("\t\tif appHostFilePath := os.Getenv(\"ASPIRE_APPHOST_FILEPATH\"); appHostFilePath != \"\" { resolved[\"appHostFilePath\"] = appHostFilePath }");
WriteLine("\t}");
WriteLine("\tif dashboardApplicationName, ok := resolved[\"dashboardApplicationName\"].(string); ok && dashboardApplicationName == \"\" {");
WriteLine("\t\tdelete(resolved, \"dashboardApplicationName\")");
WriteLine("\t}");
WriteLine();
WriteLine($"\tresult, err := c.invokeCapability(context.Background(), \"{AtsConstants.CreateBuilderCapability}\", map[string]any{{\"argsOrOptions\": resolved}})");
WriteLine("\tif err != nil { return nil, err }");
WriteLine("\thref, ok := result.(handleReference)");
WriteLine("\tif !ok { return nil, fmt.Errorf(\"aspire: createBuilder returned unexpected type %T\", result) }");
WriteLine($"\treturn &{builderImpl}{{resourceBuilderBase: newResourceBuilderBase(href.getHandle(), c)}}, nil");
WriteLine("}");
WriteLine();
}
private string? GetCreateBuilderOptionsType(AtsContext context)
{
// The TypeScript generator uses a DTO named "CreateBuilderOptions" for
// the createBuilder parameter; mirror that.
foreach (var dto in context.DtoTypes)
{
if (dto.Name.Equals("CreateBuilderOptions", StringComparison.Ordinal)
&& _dtoNames.TryGetValue(dto.TypeId, out var goName))
{
return goName;
}
}
return null;
}
// ── Type mapping ─────────────────────────────────────────────────────────
private string MapTypeRefToGo(AtsTypeRef? typeRef, bool isOptional) =>
MapTypeRefToGo(typeRef, isOptional, asInput: false);
/// <summary>
/// Maps a type in a position where the caller supplies the value (method parameter, options
/// field, DTO field, union member). A handle type with derived types maps to its accepting
/// interface there, so a derived builder can be passed; return types, callback signatures and
/// everything else use <see cref="MapTypeRefToGo(AtsTypeRef?, bool)"/>.
/// </summary>
private string MapInputTypeRefToGo(AtsTypeRef? typeRef, bool isOptional) =>
MapTypeRefToGo(typeRef, isOptional, asInput: true);
private string MapTypeRefToGo(AtsTypeRef? typeRef, bool isOptional, bool asInput)
{
if (typeRef is null)
{
return "any";
}
if (typeRef.TypeId == AtsConstants.ReferenceExpressionTypeId)
{
return "*ReferenceExpression";
}
// Mutable lists and dictionaries wrap a server-side collection and are generic over their
// element type, which Go treats as invariant: a *List[AnyX] is not a *List[X]. Their elements
// therefore stay concrete even for inputs.
var baseType = typeRef.Category switch
{
AtsTypeCategory.Primitive => MapPrimitiveType(typeRef),
AtsTypeCategory.Enum => MapEnumType(typeRef.TypeId),
AtsTypeCategory.Handle => MapHandleType(typeRef.TypeId, asInput),
AtsTypeCategory.Dto => "*" + MapDtoType(typeRef.TypeId),
AtsTypeCategory.Callback => "func(...any) any",
AtsTypeCategory.Array => $"[]{MapTypeRefToGo(typeRef.ElementType, false, asInput)}",
AtsTypeCategory.List => typeRef.IsReadOnly
? $"[]{MapTypeRefToGo(typeRef.ElementType, false, asInput)}"
: $"*List[{MapTypeRefToGo(typeRef.ElementType, false)}]",
AtsTypeCategory.Dict => typeRef.IsReadOnly
? $"map[{MapTypeRefToGo(typeRef.KeyType, false, asInput)}]{MapTypeRefToGo(typeRef.ValueType, false, asInput)}"
: $"*Dict[{MapTypeRefToGo(typeRef.KeyType, false)}, {MapTypeRefToGo(typeRef.ValueType, false)}]",
AtsTypeCategory.Union => "any",
AtsTypeCategory.Unknown => "any",
_ => "any"
};
if ((isOptional || ShouldApplyNullableType(typeRef)) && !IsNilableGoType(baseType))
{
return $"*{baseType}";
}
return baseType;
}
private string MapDtoPropertyTypeToGo(AtsTypeRef? typeRef, bool isOptional)
{
if (typeRef is null)
{
return "any";
}
if (typeRef.TypeId == AtsConstants.ReferenceExpressionTypeId)
{
return "*ReferenceExpression";
}
var baseType = typeRef.Category switch
{
AtsTypeCategory.Array or AtsTypeCategory.List => $"[]{MapDtoPropertyTypeToGo(typeRef.ElementType, false)}",
AtsTypeCategory.Dict => $"map[{MapDtoPropertyTypeToGo(typeRef.KeyType, false)}]{MapDtoPropertyTypeToGo(typeRef.ValueType, false)}",
AtsTypeCategory.Union => "any",
// DTO fields map like inputs, so a handle-typed field would become Any<Type>. That is only right for
// DTOs the caller builds: DTOs are also decoded from server results and passed to callbacks, where
// Any<Type> has no fluent methods and decodeStructFields cannot unmarshal into an interface-typed
// field. Revisit this mapping before a DTO with a handle-typed field can appear in a result.
_ => MapInputTypeRefToGo(typeRef, isOptional: false)
};
if (isOptional && !IsNilableGoType(baseType))
{
return $"*{baseType}";
}
return baseType;
}
private static bool IsNilableGoType(string typeName) =>
typeName.StartsWith("*", StringComparison.Ordinal) ||
typeName.StartsWith("[]", StringComparison.Ordinal) ||
typeName.StartsWith("map[", StringComparison.Ordinal) ||
typeName == "any" ||
typeName.StartsWith("func(", StringComparison.Ordinal);
private static bool ShouldApplyNullableType(AtsTypeRef typeRef) =>
typeRef.IsNullable == true
&& typeRef.Category is AtsTypeCategory.Primitive or AtsTypeCategory.Enum
&& typeRef.TypeId is not (AtsConstants.Void or AtsConstants.Any or AtsConstants.CancellationToken);
/// <summary>
/// Handle types map to their Go interface name, or to their accepting interface when the value
/// is an input and the type has derived types. Interfaces in Go are already reference types —
/// no leading * required.
/// </summary>
private string MapHandleType(string typeId, bool asInput)
{
if (asInput && _acceptingNames.TryGetValue(typeId, out var acceptingName))
{
return acceptingName;
}
if (_interfaceNames.TryGetValue(typeId, out var name))
{
return name;
}
return "handleReference";
}
private string MapDtoType(string typeId) =>
_dtoNames.TryGetValue(typeId, out var name) ? name : "map[string]any";
private string MapEnumType(string typeId) =>
_enumNames.TryGetValue(typeId, out var name) ? name : "string";
private static string MapPrimitiveType(AtsTypeRef typeRef) => typeRef.TypeId switch
{
AtsConstants.String or AtsConstants.Char => "string",
AtsConstants.Number => MapNumericType(typeRef.ClrType),
AtsConstants.Boolean => "bool",
AtsConstants.Void => "",
AtsConstants.Any => "any",
AtsConstants.DateTime or AtsConstants.DateTimeOffset or
AtsConstants.DateOnly or AtsConstants.TimeOnly => "string",
AtsConstants.TimeSpan => "float64",
AtsConstants.Guid or AtsConstants.Uri => "string",
AtsConstants.CancellationToken => "*CancellationToken",
_ => "any"
};
private static string MapNumericType(Type? clrType)
{
clrType = Nullable.GetUnderlyingType(clrType ?? typeof(double)) ?? clrType ?? typeof(double);
return clrType == typeof(int) ? "int"
: clrType == typeof(long) ? "int64"
: clrType == typeof(float) ? "float32"
: clrType == typeof(short) ? "int16"
: clrType == typeof(byte) ? "uint8"
: clrType == typeof(ushort) ? "uint16"
: clrType == typeof(uint) ? "uint"
: clrType == typeof(ulong) ? "uint64"
: clrType == typeof(sbyte) ? "int8"
: "float64";
}
/// <summary>
/// Capabilities the generator emits with hand-written bodies. They must be
/// skipped when iterating metadata-driven capability emission so the body
/// is not duplicated.
/// </summary>
private static bool IsSpecialCasedCapability(string capabilityId) =>
string.Equals(capabilityId, AtsConstants.BuildCapability, StringComparison.Ordinal)
|| string.Equals(capabilityId, AtsConstants.CreateBuilderCapability, StringComparison.Ordinal);
private static bool IsCancellationToken(AtsParameterInfo parameter) =>
IsCancellationTokenTypeId(parameter.Type?.TypeId);
private static bool IsCancellationTokenTypeId(string? typeId) =>
string.Equals(typeId, AtsConstants.CancellationToken, StringComparison.Ordinal)
|| (typeId?.EndsWith("/System.Threading.CancellationToken", StringComparison.Ordinal) ?? false);
// ── Identifier helpers ────────────────────────────────────────────────────
private static string ExtractTypeName(string typeId)
{
var slashIndex = typeId.IndexOf('/', StringComparison.Ordinal);
var typeName = slashIndex >= 0 ? typeId[(slashIndex + 1)..] : typeId;
var lastDot = typeName.LastIndexOf('.');
var plusIndex = typeName.LastIndexOf('+');
var delimiterIndex = Math.Max(lastDot, plusIndex);
return delimiterIndex >= 0 ? typeName[(delimiterIndex + 1)..] : typeName;
}
private static string SanitizeIdentifier(string name)
{
if (string.IsNullOrWhiteSpace(name))
{
return "_";
}
var builder = new StringBuilder(name.Length);
foreach (var ch in name)
{
builder.Append(char.IsLetterOrDigit(ch) || ch == '_' ? ch : '_');
}
if (!char.IsLetter(builder[0]) && builder[0] != '_')
{
builder.Insert(0, '_');
}
var sanitized = builder.ToString();
return s_goKeywords.Contains(sanitized) ? sanitized + "_" : sanitized;
}
private static string GetLocalIdentifier(string name) => SanitizeIdentifier(ToCamelCase(name));
// Quotes ATS metadata (capability ids, parameter and property names, exported string values) as a Go
// interpreted string literal so a quote, backslash or line break in the metadata cannot end the literal or
// the line. Control characters, U+2028/U+2029 and the byte-order mark are written as \uXXXX escapes because
// Go rejects some of them in source files and editors treat the others as line breaks. The backtick is
// escaped as well so the same literal can sit inside a raw-string struct tag. Other characters, including
// surrogate pairs, are copied as they are: the file is UTF-8 and Go rejects a \uXXXX escape for a surrogate.
private static string GoStringLiteral(string value)
{
var builder = new StringBuilder(value.Length + 2).Append('"');
foreach (var ch in value)
{
var escaped = ch switch
{
'"' => "\\\"",
'\\' => "\\\\",
'\n' => "\\n",
'\r' => "\\r",
'\t' => "\\t",
_ => null,
};
if (escaped is not null)
{
builder.Append(escaped);
}
else if (char.IsControl(ch) || ch is '`' or '\u2028' or '\u2029' or '\uFEFF')
{
builder.Append("\\u").Append(((int)ch).ToString("x4", CultureInfo.InvariantCulture));
}
else
{
builder.Append(ch);
}
}
return builder.Append('"').ToString();
}
// The capability id is part of the fmt.Errorf format string. Doubling '%' keeps an id such as
// "Tests/with%dPercent" from being read as a verb; ids without '%' pass through unchanged.
private static string GoUnexpectedTypeFormat(string capabilityId) =>
GoStringLiteral($"aspire: {capabilityId.Replace("%", "%%", StringComparison.Ordinal)} returned unexpected type %T");
private static string ToPascalCase(string name)
{
if (string.IsNullOrEmpty(name))
{
return name;
}
// Every caller emits the result as a Go identifier, so it is sanitized here for all of them.
return SanitizeIdentifier(char.IsUpper(name[0]) ? name : char.ToUpperInvariant(name[0]) + name[1..]);
}
private 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..];
}
private void WriteLine(string value = "")
{
_writer.WriteLine(value);
}
}