File: Ats\CollectionExports.cs
Web Access
Project: src\src\Aspire.Hosting\Aspire.Hosting.csproj (Aspire.Hosting)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Collections;
using System.Globalization;
 
namespace Aspire.Hosting.Ats;
 
/// <summary>
/// ATS intrinsic collection capabilities for Dict and List operations.
/// </summary>
/// <remarks>
/// <para>
/// These capabilities provide first-class support for mutable collections in polyglot app hosts.
/// Guest languages wrap these in idiomatic collection classes (e.g., AtsDict, AtsList in TypeScript).
/// </para>
/// <para>
/// <strong>Design:</strong>
/// <list type="bullet">
///   <item><description>Mutable collections (Dictionary, List) return handles to the collection</description></item>
///   <item><description>Immutable collections (IReadOnlyDictionary, IReadOnlyList, arrays) return serialized copies</description></item>
///   <item><description>All operations are async (round-trip to host)</description></item>
/// </list>
/// </para>
/// </remarks>
internal static class CollectionExports
{
    #region Dictionary Operations
 
    /// <summary>
    /// Gets a value from a dictionary by key.
    /// </summary>
    /// <param name="dict">The dictionary handle.</param>
    /// <param name="key">The key to look up.</param>
    /// <returns>The value, or null if not found.</returns>
    [AspireExport("Dict.get")]
    public static object? DictGet(this IDictionary dict, object key)
    {
        var normalizedKey = NormalizeDictionaryKey(dict, key);
        return dict.Contains(normalizedKey) ? dict[normalizedKey] : null;
    }
 
    /// <summary>
    /// Sets a value in a dictionary.
    /// </summary>
    /// <param name="dict">The dictionary handle.</param>
    /// <param name="key">The key to set.</param>
    /// <param name="value">The value to set.</param>
    [AspireExport("Dict.set")]
    public static void DictSet(this IDictionary dict, object key, object value)
        => dict[NormalizeDictionaryKey(dict, key)] = value;
 
    /// <summary>
    /// Removes a key from a dictionary.
    /// </summary>
    /// <param name="dict">The dictionary handle.</param>
    /// <param name="key">The key to remove.</param>
    /// <returns>True if the key was removed, false if not found.</returns>
    [AspireExport("Dict.remove")]
    public static bool DictRemove(this IDictionary dict, object key)
    {
        var normalizedKey = NormalizeDictionaryKey(dict, key);
        if (!dict.Contains(normalizedKey))
        {
            return false;
        }
 
        dict.Remove(normalizedKey);
        return true;
    }
 
    /// <summary>
    /// Gets all keys from a dictionary.
    /// </summary>
    /// <param name="dict">The dictionary handle.</param>
    /// <returns>An array of all keys.</returns>
    [AspireExport("Dict.keys")]
    public static object?[] DictKeys(this IDictionary dict)
        => [.. dict.Keys.Cast<object?>()];
 
    /// <summary>
    /// Checks if a dictionary contains a key.
    /// </summary>
    /// <param name="dict">The dictionary handle.</param>
    /// <param name="key">The key to check.</param>
    /// <returns>True if the key exists.</returns>
    [AspireExport("Dict.has")]
    public static bool DictHas(this IDictionary dict, object key)
        => dict.Contains(NormalizeDictionaryKey(dict, key));
 
    /// <summary>
    /// Gets the number of entries in a dictionary.
    /// </summary>
    /// <param name="dict">The dictionary handle.</param>
    /// <returns>The number of key-value pairs.</returns>
    [AspireExport("Dict.count")]
    public static int DictCount(this IDictionary dict)
        => dict.Count;
 
    /// <summary>
    /// Clears all entries from a dictionary.
    /// </summary>
    /// <param name="dict">The dictionary handle.</param>
    [AspireExport("Dict.clear")]
    public static void DictClear(this IDictionary dict)
        => dict.Clear();
 
    /// <summary>
    /// Gets all values from a dictionary.
    /// </summary>
    /// <param name="dict">The dictionary handle.</param>
    /// <returns>An array of all values.</returns>
    [AspireExport("Dict.values")]
    public static object?[] DictValues(this IDictionary dict)
        => [.. dict.Values.Cast<object?>()];
 
    /// <summary>
    /// Converts the dictionary to a plain object (creates a copy).
    /// </summary>
    /// <param name="dict">The dictionary handle.</param>
    /// <returns>A copy of the dictionary as an object.</returns>
    [AspireExport("Dict.toObject")]
    public static Dictionary<string, object?> DictToObject(this IDictionary dict)
    {
        var result = new Dictionary<string, object?>(dict.Count);
        foreach (var key in dict.Keys)
        {
            result[GetStringKey(key)] = dict[key];
        }
 
        return result;
    }
 
    private static object NormalizeDictionaryKey(IDictionary dict, object key)
    {
        var keyType = GetDictionaryKeyType(dict.GetType());
        if (keyType is null)
        {
            return key;
        }
 
        var normalizedKeyType = Nullable.GetUnderlyingType(keyType) ?? keyType;
        if (normalizedKeyType.IsInstanceOfType(key))
        {
            return key;
        }
 
        if (normalizedKeyType == typeof(string))
        {
            return key.ToString()!;
        }
 
        try
        {
            if (normalizedKeyType.IsEnum)
            {
                return key switch
                {
                    string enumName => Enum.Parse(normalizedKeyType, enumName, ignoreCase: true),
                    IConvertible convertible => Enum.ToObject(normalizedKeyType, Convert.ChangeType(convertible, Enum.GetUnderlyingType(normalizedKeyType), CultureInfo.InvariantCulture)!),
                    _ => key
                };
            }
 
            if (normalizedKeyType == typeof(Guid) && key is string guidText && Guid.TryParse(guidText, out var guid))
            {
                return guid;
            }
 
            if (key is IConvertible)
            {
                return Convert.ChangeType(key, normalizedKeyType, CultureInfo.InvariantCulture)!;
            }
        }
        catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException or ArgumentException)
        {
            // Fall back to the original key when best-effort conversion fails so callers see
            // the same "missing key" behavior as an unconvertible lookup against the underlying dictionary.
        }
 
        return key;
    }
 
    private static Type? GetDictionaryKeyType(Type type)
    {
        if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IDictionary<,>))
        {
            return type.GetGenericArguments()[0];
        }
 
        if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Dictionary<,>))
        {
            return type.GetGenericArguments()[0];
        }
 
        return type.GetInterfaces()
            .FirstOrDefault(static iface => iface.IsGenericType && iface.GetGenericTypeDefinition() == typeof(IDictionary<,>))
            ?.GetGenericArguments()[0];
    }
 
    private static string GetStringKey(object? key)
        => key as string ?? throw new InvalidOperationException($"Aspire.Hosting/Dict.toObject only supports string-key dictionaries, but found key type '{key?.GetType().FullName ?? "null"}'.");
 
    #endregion
 
    #region List Operations
 
    /// <summary>
    /// Gets an item from a list by index.
    /// </summary>
    /// <param name="list">The list handle.</param>
    /// <param name="index">The zero-based index.</param>
    /// <returns>The item at the specified index.</returns>
    [AspireExport("List.get")]
    public static object? ListGet(this IList list, int index)
        => index >= 0 && index < list.Count ? list[index] : null;
 
    /// <summary>
    /// Sets an item in a list at a specific index.
    /// </summary>
    /// <param name="list">The list handle.</param>
    /// <param name="index">The zero-based index.</param>
    /// <param name="value">The value to set.</param>
    [AspireExport("List.set")]
    public static void ListSet(this IList list, int index, object value)
    {
        if (index >= 0 && index < list.Count)
        {
            list[index] = value;
        }
    }
 
    /// <summary>
    /// Adds an item to the end of a list.
    /// </summary>
    /// <param name="list">The list handle.</param>
    /// <param name="item">The item to add.</param>
    [AspireExport("List.add")]
    public static void ListAdd(this IList list, object item)
        => list.Add(item);
 
    /// <summary>
    /// Removes an item at a specific index from a list.
    /// </summary>
    /// <param name="list">The list handle.</param>
    /// <param name="index">The zero-based index of the item to remove.</param>
    /// <returns>True if the item was removed.</returns>
    [AspireExport("List.removeAt")]
    public static bool ListRemoveAt(this IList list, int index)
    {
        if (index >= 0 && index < list.Count)
        {
            list.RemoveAt(index);
            return true;
        }
        return false;
    }
 
    /// <summary>
    /// Gets the number of items in a list.
    /// </summary>
    /// <param name="list">The list handle.</param>
    /// <returns>The number of items.</returns>
    [AspireExport("List.length")]
    public static int ListLength(this IList list)
        => list.Count;
 
    /// <summary>
    /// Clears all items from a list.
    /// </summary>
    /// <param name="list">The list handle.</param>
    [AspireExport("List.clear")]
    public static void ListClear(this IList list)
        => list.Clear();
 
    /// <summary>
    /// Inserts an item at a specific index in a list.
    /// </summary>
    /// <param name="list">The list handle.</param>
    /// <param name="index">The zero-based index at which to insert.</param>
    /// <param name="item">The item to insert.</param>
    [AspireExport("List.insert")]
    public static void ListInsert(this IList list, int index, object item)
    {
        if (index >= 0 && index <= list.Count)
        {
            list.Insert(index, item);
        }
    }
 
    /// <summary>
    /// Gets the index of an item in a list.
    /// </summary>
    /// <param name="list">The list handle.</param>
    /// <param name="item">The item to find.</param>
    /// <returns>The zero-based index, or -1 if not found.</returns>
    [AspireExport("List.indexOf")]
    public static int ListIndexOf(this IList list, object item)
        => list.IndexOf(item);
 
    /// <summary>
    /// Converts the list to an array (creates a copy).
    /// </summary>
    /// <param name="list">The list handle.</param>
    /// <returns>An array containing all items.</returns>
    [AspireExport("List.toArray")]
    public static object?[] ListToArray(this IList list)
        => [.. list.Cast<object?>()];
 
    #endregion
}