Stub generators

This commit is contained in:
2026-07-18 04:46:41 +03:00
parent ac36a3ddc7
commit cfb7f83899
6 changed files with 290 additions and 6 deletions
@@ -0,0 +1,142 @@
using System.Text;
using System.Text.RegularExpressions;
using Esiur.Data;
using Esiur.Data.Types;
namespace Esiur.CLI.Generation;
/// <summary>
/// Emits TypeScript interfaces (and enums) for a resource's <see cref="TypeDef"/>
/// and every type it transitively references, for use with esiur-ts's
/// `connection.get&lt;T&gt;(path)` / `warehouse.get&lt;T&gt;(url, ...)`. Since
/// esiur-ts resolves resources dynamically at runtime (TypeDefs are fetched
/// over the wire, not compiled in), the generated output is pure type-level
/// TypeScript — no runtime code is needed for the interfaces themselves.
/// </summary>
public static class TypeScriptStubGenerator
{
public static string Generate(TypeDef root, string sourcePath)
{
var discovered = new Dictionary<ulong, TypeDef>();
var order = new List<TypeDef>();
Collect(root, discovered, order);
var names = AssignNames(order);
var usesDecimal128 = order.Any(typeDef =>
typeDef.Properties.Any(p => TypeScriptTypeMapper.UsesDecimal128(p.ValueType))
|| typeDef.Functions.Any(f => TypeScriptTypeMapper.UsesDecimal128(f.ReturnType)
|| (f.Arguments ?? []).Any(a => TypeScriptTypeMapper.UsesDecimal128(a.Type)))
|| typeDef.Events.Any(e => TypeScriptTypeMapper.UsesDecimal128(e.ArgumentType))
|| typeDef.Constants.Any(c => TypeScriptTypeMapper.UsesDecimal128(c.ValueType)));
var sb = new StringBuilder();
sb.AppendLine($"// Generated by `esiur generate typescript {sourcePath}` — do not edit by hand.");
sb.AppendLine($"// Regenerate with: esiur generate typescript {sourcePath} --out <file>");
if (usesDecimal128) sb.AppendLine("import type { Decimal128 } from \"esiur\";");
sb.AppendLine();
foreach (var typeDef in order) EmitType(sb, typeDef, names);
return sb.ToString();
}
static void Collect(TypeDef typeDef, Dictionary<ulong, TypeDef> discovered, List<TypeDef> order)
{
if (!discovered.TryAdd(typeDef.Id, typeDef)) return;
order.Add(typeDef);
foreach (var property in typeDef.Properties) CollectFromTru(property.ValueType, discovered, order);
foreach (var function in typeDef.Functions)
{
foreach (var argument in function.Arguments ?? [])
CollectFromTru(argument.Type, discovered, order);
CollectFromTru(function.ReturnType, discovered, order);
}
foreach (var @event in typeDef.Events) CollectFromTru(@event.ArgumentType, discovered, order);
foreach (var constant in typeDef.Constants) CollectFromTru(constant.ValueType, discovered, order);
}
static void CollectFromTru(Tru? type, Dictionary<ulong, TypeDef> discovered, List<TypeDef> order)
{
switch (type)
{
case TruTypeDef { TypeDef: not null } reference:
Collect(reference.TypeDef, discovered, order);
break;
case TruComposite composite:
foreach (var sub in composite.SubTypes) CollectFromTru(sub, discovered, order);
break;
}
}
/// <summary>
/// A TypeDef's <c>Name</c> is often a fully-qualified CLR name (e.g.
/// <c>MyApp.Server.Counter</c>), not a valid TypeScript identifier. Use the
/// last segment as the interface/enum name, falling back to the full name
/// (sanitized) on collision so two distinct types never collide.
/// </summary>
static Dictionary<ulong, string> AssignNames(List<TypeDef> order)
{
var used = new HashSet<string>(StringComparer.Ordinal);
var names = new Dictionary<ulong, string>();
foreach (var typeDef in order)
{
var shortName = Sanitize(LastSegment(typeDef.Name));
var name = used.Add(shortName) ? shortName : Sanitize(typeDef.Name);
used.Add(name);
names[typeDef.Id] = name;
}
return names;
}
static string LastSegment(string fullName)
{
var lastDot = fullName.LastIndexOf('.');
return lastDot >= 0 ? fullName[(lastDot + 1)..] : fullName;
}
static string Sanitize(string name)
{
var cleaned = Regex.Replace(name, "[^A-Za-z0-9_$]", "_");
return cleaned.Length > 0 && char.IsDigit(cleaned[0]) ? "_" + cleaned : cleaned;
}
static void EmitType(StringBuilder sb, TypeDef typeDef, IReadOnlyDictionary<ulong, string> names)
{
if (typeDef.Kind == TypeDefKind.Enum) EmitEnum(sb, typeDef, names);
else EmitInterface(sb, typeDef, names);
}
static void EmitEnum(StringBuilder sb, TypeDef typeDef, IReadOnlyDictionary<ulong, string> names)
{
sb.AppendLine($"export enum {names[typeDef.Id]} {{");
foreach (var constant in typeDef.Constants.OrderBy(c => c.Index))
sb.AppendLine($" {constant.Name} = {Convert.ToInt64(constant.Value)},");
sb.AppendLine("}");
sb.AppendLine();
}
static void EmitInterface(StringBuilder sb, TypeDef typeDef, IReadOnlyDictionary<ulong, string> names)
{
sb.AppendLine($"export interface {names[typeDef.Id]} {{");
foreach (var property in typeDef.Properties.OrderBy(p => p.Index))
{
var readonlyModifier = property.ReadOnly || property.Constant ? "readonly " : "";
sb.AppendLine(
$" {readonlyModifier}{property.Name}: {TypeScriptTypeMapper.Map(property.ValueType, names)};");
}
foreach (var function in typeDef.Functions.OrderBy(f => f.Index))
{
var args = string.Join(", ", (function.Arguments ?? [])
.OrderBy(a => a.Index)
.Select(a => $"{a.Name}{(a.Optional ? "?" : "")}: {TypeScriptTypeMapper.Map(a.Type, names)}"));
var returnType = TypeScriptTypeMapper.Map(function.ReturnType, names);
sb.AppendLine($" {function.Name}({args}): PromiseLike<{returnType}>;");
}
sb.AppendLine("}");
sb.AppendLine();
}
}
@@ -0,0 +1,80 @@
using Esiur.Data;
namespace Esiur.CLI.Generation;
/// <summary>
/// Maps a wire-level <see cref="Tru"/> type representation onto the closest
/// TypeScript type that esiur-ts's runtime actually decodes it into (e.g.
/// 64-bit integers become `bigint`, not `number` — see
/// esiur-ts/src/data/DataDeserializer.ts) so generated stubs match runtime
/// values, not just wire shape.
/// </summary>
public static class TypeScriptTypeMapper
{
/// <param name="names">Maps a referenced TypeDef's id to the identifier name
/// it was declared under (see <see cref="TypeScriptStubGenerator"/>) — a
/// TypeDef's own <c>Name</c> is often a fully-qualified CLR name like
/// <c>MyApp.Server.Counter</c>, not a valid TypeScript identifier.</param>
public static string Map(Tru? type, IReadOnlyDictionary<ulong, string> names)
{
if (type is null) return "unknown";
var mapped = MapCore(type, names);
return type.Nullable && mapped != "void" ? $"{mapped} | null" : mapped;
}
static string MapCore(Tru type, IReadOnlyDictionary<ulong, string> names) => type switch
{
TruTypeDef { TypeDef: not null } reference =>
names.TryGetValue(reference.TypeDef.Id, out var name) ? name : "unknown",
TruTypeDef => "unknown",
TruComposite composite => MapComposite(composite, names),
TruPrimitive primitive => MapPrimitive(primitive.Identifier),
_ => "unknown",
};
static string MapComposite(TruComposite composite, IReadOnlyDictionary<ulong, string> names) =>
composite.Identifier switch
{
TruIdentifier.TypedList => $"{Map(composite.SubTypes[0], names)}[]",
TruIdentifier.TypedMap =>
$"Map<{Map(composite.SubTypes[0], names)}, {Map(composite.SubTypes[1], names)}>",
TruIdentifier.Tuple2 or TruIdentifier.Tuple3 or TruIdentifier.Tuple4
or TruIdentifier.Tuple5 or TruIdentifier.Tuple6 or TruIdentifier.Tuple7 =>
$"[{string.Join(", ", composite.SubTypes.Select(sub => Map(sub, names)))}]",
_ => "unknown",
};
static string MapPrimitive(TruIdentifier identifier) => identifier switch
{
TruIdentifier.Void => "void",
TruIdentifier.Dynamic => "unknown",
TruIdentifier.Bool => "boolean",
TruIdentifier.Char => "string",
TruIdentifier.UInt8 or TruIdentifier.Int8
or TruIdentifier.UInt16 or TruIdentifier.Int16
or TruIdentifier.UInt32 or TruIdentifier.Int32
or TruIdentifier.Float32 or TruIdentifier.Float64 => "number",
// esiur-ts decodes 64-bit integers as `bigint` to avoid precision loss.
TruIdentifier.Int64 or TruIdentifier.UInt64 => "bigint",
// esiur-ts decodes Decimal as its own Decimal128 class (exported from "esiur").
TruIdentifier.Decimal => "Decimal128",
TruIdentifier.String => "string",
TruIdentifier.DateTime => "Date",
TruIdentifier.RawData => "Uint8Array",
TruIdentifier.Resource => "unknown",
TruIdentifier.Record => "unknown",
TruIdentifier.List => "unknown[]",
TruIdentifier.Map => "Map<unknown, unknown>",
_ => "unknown",
};
/// <summary>True when any type reachable from <paramref name="type"/> maps to Decimal128,
/// so the emitter knows whether to import it.</summary>
public static bool UsesDecimal128(Tru? type) => type switch
{
null => false,
TruPrimitive primitive => primitive.Identifier == TruIdentifier.Decimal,
TruComposite composite => composite.SubTypes.Any(UsesDecimal128),
_ => false,
};
}