mirror of
https://github.com/esiur/esiur-dotnet.git
synced 2026-07-31 01:40:42 +00:00
Stub generators
This commit is contained in:
@@ -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<T>(path)` / `warehouse.get<T>(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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user