Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions global.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"sdk": {
"version": "10.0.100",
"version": "11.0.100-preview.5.26302.115",
"rollForward": "latestFeature",
"allowPrerelease": false
"allowPrerelease": true
}
}
365 changes: 359 additions & 6 deletions src/Nullean.Argh.Generator/CliParserGenerator.cs

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/Nullean.Make/Discovery/TargetNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ internal sealed class TargetNode
/// <summary>Typed body delegate: Action&lt;T&gt; or Func&lt;T,Task&gt;. Null when no DTO.</summary>
public Delegate? TypedBody { get; set; }

/// <summary>Route-based dep type names used by <see cref="UnionMakeApp{TUnion}"/> before node resolution.</summary>
public List<string> RouteRequires { get; } = new();
public List<string> RouteComposes { get; } = new();

/// <summary>Resolved dep nodes after the graph is built.</summary>
public List<TargetNode> RequiresResolved { get; } = new();
public List<TargetNode> ComposesResolved { get; } = new();
Expand Down
53 changes: 53 additions & 0 deletions src/Nullean.Make/UnionGraph/IUnionTargetBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
namespace Nullean.Make.UnionGraph;

/// <summary>
/// Fluent builder returned from <see cref="UnionMakeApp{TUnion}.Target()"/> and
/// <see cref="UnionMakeApp{TUnion}.Command()"/> inside the <c>app.Bind(...)</c> lambda.
/// <para>
/// The lambda is called twice per case: once at graph-build time with default values (for
/// metadata), and once at execution time with real CLI-parsed values (for the Executes body).
/// </para>
/// </summary>
public interface IUnionTargetBuilder<TUnion>
{
/// <summary>Sets the human-readable description shown in help output.</summary>
IUnionTargetBuilder<TUnion> Description(string text);

/// <summary>Hides this target from default help output.</summary>
IUnionTargetBuilder<TUnion> Hidden();

/// <summary>
/// Declares dependencies by passing union values.
/// Implicit union conversion applies: <c>DependsOn(new Clean(), new Build())</c>
/// when <c>Clean</c>/<c>Build</c> are case types of <typeparamref name="TUnion"/>.
/// </summary>
IUnionTargetBuilder<TUnion> DependsOn(params TUnion[] deps);

/// <summary>Declares a single dependency by case type. The case type must have a parameterless (or all-default) constructor.</summary>
IUnionTargetBuilder<TUnion> DependsOn<T1>() where T1 : new();

/// <summary>Declares two dependencies by case types.</summary>
IUnionTargetBuilder<TUnion> DependsOn<T1, T2>() where T1 : new() where T2 : new();

/// <summary>Declares three dependencies by case types.</summary>
IUnionTargetBuilder<TUnion> DependsOn<T1, T2, T3>()
where T1 : new() where T2 : new() where T3 : new();

/// <summary>Declares four dependencies by case types.</summary>
IUnionTargetBuilder<TUnion> DependsOn<T1, T2, T3, T4>()
where T1 : new() where T2 : new() where T3 : new() where T4 : new();

/// <summary>
/// Marks this target as a command: it composes other targets that always run (not skippable with <c>-s</c>).
/// </summary>
IUnionTargetBuilder<TUnion> Composes(params TUnion[] targets);

/// <summary>Composes targets by case type.</summary>
IUnionTargetBuilder<TUnion> Composes<T1>() where T1 : new();

/// <summary>Registers a synchronous execution body. Closed-over case values are the real CLI-parsed ones at execution time.</summary>
IUnionTargetBuilder<TUnion> Executes(Action body);

/// <summary>Registers an asynchronous execution body.</summary>
IUnionTargetBuilder<TUnion> Executes(Func<Task> body);
}
47 changes: 47 additions & 0 deletions src/Nullean.Make/UnionGraph/UnionOptionRef.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
namespace Nullean.Make.UnionGraph;

/// <summary>
/// Mutable handle to a global option declared on <see cref="UnionMakeApp{TUnion}"/>.
/// Obtain via <see cref="UnionMakeApp{TUnion}.Flag"/> or <see cref="UnionMakeApp{TUnion}.Option{T}"/>.
/// The value is pre-populated during argv extraction, before any target body runs.
/// </summary>
public sealed class UnionOptionRef<T>
{
private readonly Func<string, T> _parser;
private T _value;

internal UnionOptionRef(string longName, string? shortName, string? description, T defaultValue, Func<string, T> parser)
{
Long = longName;
Short = shortName;
Description = description;
DefaultValue = defaultValue;
_value = defaultValue;
_parser = parser;
}

/// <summary>Long flag name, e.g. <c>--token</c>.</summary>
public string Long { get; }

/// <summary>Short flag name, e.g. <c>-t</c>. Null when not declared.</summary>
public string? Short { get; }

/// <summary>Description shown in help output.</summary>
public string? Description { get; }

/// <summary>Default value used when the flag is not supplied.</summary>
public T DefaultValue { get; }

/// <summary>Current parsed value — valid after <c>RunAsync</c> begins argv extraction.</summary>
public T Value => _value;

internal void Set(string raw) => _value = _parser(raw);
internal void Reset() => _value = DefaultValue;
}

internal sealed record UnionOptionDecl(
string Long,
string? Short,
string? Description,
bool IsFlag,
Action<string> Set);
115 changes: 115 additions & 0 deletions src/Nullean.Make/UnionGraph/UnionReflector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
using System.Reflection;

namespace Nullean.Make.UnionGraph;

/// <summary>Runtime reflection helpers for C# 15 union types in Make pipelines.</summary>
internal static class UnionReflector
{
/// <summary>True when <paramref name="t"/> is a C# 15 union type (implements IUnion, carries [Union], or matches the structural pattern).</summary>
internal static bool IsUnionType(Type t)
{
if (t.GetInterfaces().Any(i => i.Name == "IUnion"))
return true;
if (t.GetCustomAttributesData().Any(a => a.AttributeType.Name == "UnionAttribute"))
return true;
// Structural fallback: has object? Value property + ≥1 single-param public constructor
var valueProp = t.GetProperty("Value", BindingFlags.Public | BindingFlags.Instance);
return valueProp is not null
&& (valueProp.PropertyType == typeof(object) || valueProp.PropertyType == typeof(object))
&& GetUnionCtors(t).Length > 0;
}

/// <summary>Returns all single-parameter public constructors; each parameter type is a union case type.</summary>
internal static ConstructorInfo[] GetUnionCtors(Type t)
=> t.GetConstructors(BindingFlags.Public | BindingFlags.Instance)
.Where(c => c.GetParameters().Length == 1)
.ToArray();

/// <summary>
/// Recursively enumerates all leaf case paths in the union hierarchy.
/// Nested union case types become namespace prefix segments in the route.
/// </summary>
internal static List<CasePath> GetCasePaths(Type unionType)
{
var result = new List<CasePath>();
Collect(unionType, [], result);
return result;
}

private static void Collect(Type unionType, string[] prefix, List<CasePath> result)
{
foreach (var ctor in GetUnionCtors(unionType))
{
var caseType = ctor.GetParameters()[0].ParameterType;
var segment = ToKebabCase(caseType.Name);
if (IsUnionType(caseType))
Collect(caseType, [..prefix, segment], result);
else
result.Add(new CasePath([..prefix, segment], caseType, ctor));
}
}

/// <summary>Constructs a union value wrapping a default instance of <paramref name="caseType"/>.</summary>
internal static TUnion ConstructDefault<TUnion>(Type caseType, ConstructorInfo unionCtor)
=> (TUnion)unionCtor.Invoke([CreateDefaultInstance(caseType)]);

/// <summary>
/// Constructs the outermost <typeparamref name="TUnion"/> by walking up the nested ctor chain
/// from <paramref name="caseInstance"/> to the top of the union hierarchy.
/// </summary>
internal static TUnion ConstructUnion<TUnion>(Type outerUnionType, Type caseType, object caseInstance, string[] route)
=> (TUnion)WrapValue(outerUnionType, caseType, caseInstance, route, 0);

private static object WrapValue(Type unionType, Type leafCaseType, object leafInstance, string[] route, int depth)
{
foreach (var ctor in GetUnionCtors(unionType))
{
var paramType = ctor.GetParameters()[0].ParameterType;
if (paramType == leafCaseType)
return ctor.Invoke([leafInstance]);
if (IsUnionType(paramType) && depth < route.Length - 1
&& ToKebabCase(paramType.Name) == route[depth])
{
var inner = WrapValue(paramType, leafCaseType, leafInstance, route, depth + 1);
return ctor.Invoke([inner]);
}
}
throw new InvalidOperationException($"Cannot wrap {leafCaseType.Name} into {unionType.Name}.");
}

/// <summary>Builds a map of case-type simple name → full route key for fast dep resolution.</summary>
internal static Dictionary<string, string> BuildTypeNameToRouteMap(Type unionType)
{
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var path in GetCasePaths(unionType))
map[path.CaseType.Name] = string.Join("/", path.Route);
return map;
}

private static object CreateDefaultInstance(Type t)
{
if (t.IsValueType) return Activator.CreateInstance(t)!;
var ctor = t.GetConstructors()
.OrderByDescending(c => c.GetParameters().Length)
.FirstOrDefault(c => c.GetParameters().All(p => p.HasDefaultValue));
if (ctor is not null)
return ctor.Invoke(ctor.GetParameters().Select(p => p.DefaultValue).ToArray());
return Activator.CreateInstance(t)!;
}

internal static string ToKebabCase(string name)
{
if (string.IsNullOrEmpty(name)) return name;
var sb = new System.Text.StringBuilder();
for (var i = 0; i < name.Length; i++)
{
var c = name[i];
if (char.IsUpper(c) && i > 0) sb.Append('-');
sb.Append(char.ToLowerInvariant(c));
}
return sb.ToString();
}
}

/// <summary>A leaf path in the union case hierarchy: the CLI route, the record case type, and the wrapping constructor.</summary>
internal sealed record CasePath(string[] Route, Type CaseType, ConstructorInfo UnionCtor);
105 changes: 105 additions & 0 deletions src/Nullean.Make/UnionGraph/UnionTargetBuilderImpl.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
using Nullean.Make.Discovery;

namespace Nullean.Make.UnionGraph;

/// <summary>
/// Internal implementation of <see cref="IUnionTargetBuilder{TUnion}"/>.
/// A fresh instance is returned by every call to <c>app.Target()</c> / <c>app.Command()</c>.
/// <see cref="UnionMakeApp{TUnion}"/> calls <c>app.Bind</c> twice: at graph-build time
/// (reads Kind/Description/DepTypeNames) and at execution time (reads SyncBody/AsyncBody).
/// </summary>
internal sealed class UnionTargetBuilderImpl<TUnion> : IUnionTargetBuilder<TUnion>
{
private string? _description;
private bool _hidden;
private Action? _syncBody;
private Func<Task>? _asyncBody;
private readonly TargetKind _kind;
private readonly List<string> _depTypeNames = new();
private readonly List<string> _compTypeNames = new();

internal UnionTargetBuilderImpl(TargetKind kind = TargetKind.Target) => _kind = kind;

// ── IUnionTargetBuilder<TUnion> ──────────────────────────────────────────

public IUnionTargetBuilder<TUnion> Description(string text) { _description = text; return this; }
public IUnionTargetBuilder<TUnion> Hidden() { _hidden = true; return this; }
public IUnionTargetBuilder<TUnion> Executes(Action body) { _syncBody = body; return this; }
public IUnionTargetBuilder<TUnion> Executes(Func<Task> body) { _asyncBody = body; return this; }

public IUnionTargetBuilder<TUnion> DependsOn(params TUnion[] deps)
{
foreach (var dep in deps) _depTypeNames.Add(GetLeafCaseName(dep));
return this;
}

public IUnionTargetBuilder<TUnion> DependsOn<T1>() where T1 : new()
{ _depTypeNames.Add(typeof(T1).Name); return this; }

public IUnionTargetBuilder<TUnion> DependsOn<T1, T2>() where T1 : new() where T2 : new()
{ _depTypeNames.Add(typeof(T1).Name); _depTypeNames.Add(typeof(T2).Name); return this; }

public IUnionTargetBuilder<TUnion> DependsOn<T1, T2, T3>()
where T1 : new() where T2 : new() where T3 : new()
{
_depTypeNames.Add(typeof(T1).Name);
_depTypeNames.Add(typeof(T2).Name);
_depTypeNames.Add(typeof(T3).Name);
return this;
}

public IUnionTargetBuilder<TUnion> DependsOn<T1, T2, T3, T4>()
where T1 : new() where T2 : new() where T3 : new() where T4 : new()
{
_depTypeNames.Add(typeof(T1).Name);
_depTypeNames.Add(typeof(T2).Name);
_depTypeNames.Add(typeof(T3).Name);
_depTypeNames.Add(typeof(T4).Name);
return this;
}

public IUnionTargetBuilder<TUnion> Composes(params TUnion[] targets)
{
foreach (var t in targets) _compTypeNames.Add(GetLeafCaseName(t));
return this;
}

public IUnionTargetBuilder<TUnion> Composes<T1>() where T1 : new()
{ _compTypeNames.Add(typeof(T1).Name); return this; }

// ── Internal accessors ────────────────────────────────────────────────────

internal TargetKind Kind => _kind;
internal string? DescriptionValue => _description;
internal bool IsHidden => _hidden;
internal Action? SyncBody => _syncBody;
internal Func<Task>? AsyncBody => _asyncBody;
internal IReadOnlyList<string> DepTypeNames => _depTypeNames;
internal IReadOnlyList<string> CompTypeNames => _compTypeNames;

internal async Task ExecuteAsync()
{
if (_asyncBody is not null) await _asyncBody();
else _syncBody?.Invoke();
}

/// <summary>
/// Extracts the leaf (innermost non-union) case type name from a union value.
/// Works for both flat and nested unions.
/// </summary>
private static string GetLeafCaseName(TUnion dep)
{
var valueProp = typeof(TUnion).GetProperty("Value",
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
var inner = valueProp?.GetValue(dep);
if (inner is null) return "";
var current = inner;
while (UnionReflector.IsUnionType(current.GetType()))
{
var vp = current.GetType().GetProperty("Value",
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
current = vp?.GetValue(current) ?? current;
}
return current.GetType().Name;
}
}
Loading
Loading