diff --git a/ebuild.Tests/Unit/CliParserConverterTests.cs b/ebuild.Tests/Unit/CliParserConverterTests.cs new file mode 100644 index 0000000..0ef8ac1 --- /dev/null +++ b/ebuild.Tests/Unit/CliParserConverterTests.cs @@ -0,0 +1,49 @@ +using NUnit.Framework; +using System.Collections.Generic; +using ebuild.cli; + +namespace ebuild.Tests.Unit +{ + [TestFixture] + public class CliParserConverterTests + { + // Simple converter used by tests + public class UpperCaseConverter : IStringConverter + { + public object Convert(string value) => value.ToUpperInvariant(); + } + + class TestConvRoot : Command + { + [Option("opt", ConverterType = typeof(UpperCaseConverter))] + public string? Opt; + } + + [Test] + public void Uses_IStringConverter_for_field() + { + var parser = new CliParser(typeof(TestConvRoot)); + parser.Parse(new[] { "cmd", "--opt=hello" }); + parser.ApplyParsedToCommands(); + var root = (TestConvRoot)parser.currentCommandChain.First!.Value; + Assert.That(root.Opt, Is.EqualTo("HELLO")); + } + + class TestDictRoot : Command + { + [Option("map")] + public Dictionary? Map; + } + + [Test] + public void Dictionary_values_are_converted_to_int() + { + var parser = new CliParser(typeof(TestDictRoot)); + parser.Parse(new[] { "cmd", "--map=foo=42", "--map=bar=7" }); + parser.ApplyParsedToCommands(); + var root = (TestDictRoot)parser.currentCommandChain.First!.Value; + Assert.That(root.Map.ContainsKey("foo") && root.Map["foo"] == 42); + Assert.That(root.Map.ContainsKey("bar") && root.Map["bar"] == 7); + } + } +} diff --git a/ebuild.Tests/Unit/CliParserSubcommandTests.cs b/ebuild.Tests/Unit/CliParserSubcommandTests.cs new file mode 100644 index 0000000..e473999 --- /dev/null +++ b/ebuild.Tests/Unit/CliParserSubcommandTests.cs @@ -0,0 +1,96 @@ +using NUnit.Framework; +using ebuild.cli; + +namespace ebuild.Tests.Unit +{ + [TestFixture] + public class CliParserSubcommandTests + { + [Command("sub", Aliases = new[] { "s" })] + class SubCommand : Command + { + [Option("flag")] + public bool Flag; + } + + class RootWithSub : Command + { + // root has no options; subcommand added at runtime + } + + class RootWithNested : Command + { + [Command("nested")] + public class Nested : Command + { + [Option("flag")] + public bool Flag; + } + } + + class RootWithNestedOptOut : Command + { + [Command("noauto", AutoRegister = false)] + public class NoAuto : Command + { + } + } + + [Test] + public void Subcommand_switches_and_parses_option() + { + var parser = new CliParser(typeof(RootWithSub)); + // add a subcommand instance to the root created inside the parser + var root = (RootWithSub)parser.currentCommandChain.First!.Value; + var sub = new SubCommand(); + root.AddSubCommand(sub); + + parser.Parse(new[] { "cmd", "sub", "--flag" }); + parser.ApplyParsedToCommands(); + + // ensure current command chain ended on the subcommand instance and its option was set + var last = parser.currentCommandChain.Last!.Value as SubCommand; + Assert.That(last, Is.Not.Null); + Assert.That(last!.Flag, Is.True); + } + + [Test] + public void Subcommand_alias_is_recognized() + { + var parser = new CliParser(typeof(RootWithSub)); + var root = (RootWithSub)parser.currentCommandChain.First!.Value; + var sub = new SubCommand(); + root.AddSubCommand(sub); + + parser.Parse(new[] { "cmd", "s", "--flag" }); + parser.ApplyParsedToCommands(); + + var last = parser.currentCommandChain.Last!.Value as SubCommand; + Assert.That(last, Is.Not.Null); + Assert.That(last!.Flag, Is.True); + } + + [Test] + public void Nested_subcommand_is_auto_registered() + { + var parser = new CliParser(typeof(RootWithNested)); + parser.Parse(new[] { "cmd", "nested", "--flag" }); + parser.ApplyParsedToCommands(); + + var last = parser.currentCommandChain.Last!.Value as RootWithNested.Nested; + Assert.That(last, Is.Not.Null); + Assert.That(last!.Flag, Is.True); + } + + [Test] + public void Nested_subcommand_opt_out_prevents_registration() + { + var parser = new CliParser(typeof(RootWithNestedOptOut)); + parser.Parse(new[] { "cmd", "noauto" }); + parser.ApplyParsedToCommands(); + + // should remain on root because nested class opted out + Assert.That(parser.currentCommandChain.Last!.Value.GetType(), Is.EqualTo(typeof(RootWithNestedOptOut))); + } + } +} diff --git a/ebuild.Tests/Unit/CliParserUnitTests.cs b/ebuild.Tests/Unit/CliParserUnitTests.cs new file mode 100644 index 0000000..985dd34 --- /dev/null +++ b/ebuild.Tests/Unit/CliParserUnitTests.cs @@ -0,0 +1,121 @@ +using NUnit.Framework; +using System; +using ebuild.cli; + +namespace ebuild.Tests.Unit +{ + [TestFixture] + public class CliParserTests + { + class TestRoot : Command + { + [Option("verbose", ShortName = "v")] + public bool Verbose; + + [Option("define", ShortName = "D")] + public string? Define; + [Argument(0, Name = "input")] + public string? Input; + } + + [Test] + public void Parses_long_option_with_equals() + { + var parser = new CliParser(typeof(TestRoot)); + parser.Parse(new[] { "cmd", "--define=Z=1" }); + parser.ApplyParsedToCommands(); + var root = (TestRoot)parser.currentCommandChain.First!.Value; + Assert.That(root.Define, Is.EqualTo("Z=1")); + } + + [Test] + public void Negative_number_is_argument_when_no_option() + { + var parser = new CliParser(typeof(TestRoot)); + parser.Parse(new[] { "cmd", "-42", "foo" }); + parser.ApplyParsedToCommands(); + Assert.That(parser.ParsedOptions.Count, Is.EqualTo(0)); + Assert.That(parser.ParsedArguments.Count, Is.EqualTo(2)); + Assert.That(parser.ParsedArguments[0].Value, Is.EqualTo("-42")); + } + + [Test] + public void Positional_argument_maps_to_field() + { + var parser = new CliParser(typeof(TestRoot)); + parser.Parse(new[] { "cmd", "input.txt" }); + parser.ApplyParsedToCommands(); + var root = (TestRoot)parser.currentCommandChain.First!.Value; + Assert.That(root.Input, Is.EqualTo("input.txt")); + } + + [Test] + public void Duplicate_option_policy_error_throws() + { + var parser = new CliParser(typeof(TestRoot), DuplicateOptionPolicy.Error); + parser.Parse(new[] { "cmd", "--define=one", "--define=two" }); + Assert.Throws(() => parser.ApplyParsedToCommands()); + } + + [Test] + public void Duplicate_option_policy_warn_last_wins() + { + var parser = new CliParser(typeof(TestRoot), DuplicateOptionPolicy.Warn); + parser.Parse(new[] { "cmd", "--define=one", "--define=two" }); + parser.ApplyParsedToCommands(); + var root = (TestRoot)parser.currentCommandChain.First!.Value; + Assert.That(root.Define, Is.EqualTo("two")); + } + + [Test] + public void Duplicate_option_policy_ignore_last_wins() + { + var parser = new CliParser(typeof(TestRoot), DuplicateOptionPolicy.Ignore); + parser.Parse(new[] { "cmd", "--define=one", "--define=two" }); + parser.ApplyParsedToCommands(); + var root = (TestRoot)parser.currentCommandChain.First!.Value; + Assert.That(root.Define, Is.EqualTo("two")); + } + + [Test] + public void Disallow_combined_short_flags() + { + var parser = new CliParser(typeof(TestRoot)); + parser.Parse(new[] { "cmd", "-abc" }); + parser.ApplyParsedToCommands(); + // Should treat as a single option name 'abc' + Assert.That(parser.ParsedOptions.Count, Is.EqualTo(1)); + Assert.That(parser.ParsedOptions[0].Name, Is.EqualTo("abc")); + } + + [Test] + public void Short_option_attached_value_with_equals_parses() + { + var parser = new CliParser(typeof(TestRoot)); + parser.Parse(new[] { "cmd", "-DZLIB=1.2.11" }); + parser.ApplyParsedToCommands(); + var root = (TestRoot)parser.currentCommandChain.First!.Value; + Assert.That(root.Define, Is.EqualTo("ZLIB=1.2.11")); + } + + [Test] + public void Short_option_attached_quoted_value_parses() + { + var parser = new CliParser(typeof(TestRoot)); + parser.Parse(new[] { "cmd", "-D\"USE_ZLIB\"" }); + parser.ApplyParsedToCommands(); + var root = (TestRoot)parser.currentCommandChain.First!.Value; + Assert.That(root.Define, Is.EqualTo("USE_ZLIB")); + } + + [Test] + public void Short_option_attached_quoted_value_with_spaces_parses() + { + var parser = new CliParser(typeof(TestRoot)); + parser.Parse(new[] { "cmd", "-D\"Use ZLIB Data\"" }); + parser.ApplyParsedToCommands(); + var root = (TestRoot)parser.currentCommandChain.First!.Value; + Assert.That(root.Define, Is.EqualTo("Use ZLIB Data")); + } + } +} diff --git a/ebuild.Tests/ebuild.Tests.csproj b/ebuild.Tests/ebuild.Tests.csproj index f93de74..8a291ef 100644 --- a/ebuild.Tests/ebuild.Tests.csproj +++ b/ebuild.Tests/ebuild.Tests.csproj @@ -13,6 +13,7 @@ + enable diff --git a/ebuild.api/Definition.cs b/ebuild.api/Definition.cs index 1a75c98..4352e22 100644 --- a/ebuild.api/Definition.cs +++ b/ebuild.api/Definition.cs @@ -2,7 +2,6 @@ namespace ebuild.api { - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] /// /// Represents a single preprocessor/definition entry typically specified as /// a string in the form NAME=VALUE or simply NAME. @@ -15,6 +14,7 @@ namespace ebuild.api /// /// The raw definition string passed to the constructor. /// Expected formats: NAME=VALUE or NAME. + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] public class Definition(string inValue) { /// diff --git a/ebuild.api/ModuleBase.cs b/ebuild.api/ModuleBase.cs index 7fb1625..3dcc0eb 100644 --- a/ebuild.api/ModuleBase.cs +++ b/ebuild.api/ModuleBase.cs @@ -412,9 +412,10 @@ public string GetOutputTransformerName() /// Absolute path to the module's binary output directory, always ending with a directory separator. public string GetBinaryOutputDirectory() { + var basePath = Path.Combine(Context.ModuleDirectory!.FullName, OutputDirectory, Context.Configuration, GetOutputTransformerName()); if (UseVariants) - return Path.TrimEndingDirectorySeparator(Path.Combine(Context.ModuleDirectory!.FullName, OutputDirectory, GetOutputTransformerName(), GetVariantId().ToString()) + Path.DirectorySeparatorChar); - return Path.TrimEndingDirectorySeparator(Path.Combine(Context.ModuleDirectory!.FullName, OutputDirectory, GetOutputTransformerName()) + Path.DirectorySeparatorChar); + return Path.TrimEndingDirectorySeparator(Path.Combine(basePath, GetVariantId().ToString())); + return Path.TrimEndingDirectorySeparator(basePath); } diff --git a/ebuild.api/ModuleContext.cs b/ebuild.api/ModuleContext.cs index bb0ddcb..358fd69 100644 --- a/ebuild.api/ModuleContext.cs +++ b/ebuild.api/ModuleContext.cs @@ -4,7 +4,6 @@ namespace ebuild.api { - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] /// /// Runtime context provided to modules when they are instantiated. /// @@ -12,6 +11,7 @@ namespace ebuild.api /// module reference, target platform, toolchain, architecture, and option map. /// Module implementations use this context during construction and initialization. /// + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] public class ModuleContext { /// @@ -27,6 +27,7 @@ public ModuleContext(ModuleContext m) Toolchain = m.Toolchain; TargetArchitecture = m.TargetArchitecture; Options = m.Options; + Configuration = m.Configuration; AdditionalDependencyPaths = m.AdditionalDependencyPaths; } @@ -107,7 +108,6 @@ public ModuleContext(ModuleReference reference, PlatformBase platform, IToolchai /// public string RequestedOutput => SelfReference.GetOutput(); - /// /// Simple message wrapper used to surface informational/warning/error messages produced /// during module instantiation. diff --git a/ebuild.cli/ArgumentAttribute.cs b/ebuild.cli/ArgumentAttribute.cs new file mode 100644 index 0000000..348167b --- /dev/null +++ b/ebuild.cli/ArgumentAttribute.cs @@ -0,0 +1,19 @@ +using System; + +namespace ebuild.cli +{ + [AttributeUsage(AttributeTargets.Field, AllowMultiple = false)] + public class ArgumentAttribute : Attribute + { + public int Order { get; } + public string? Name { get; set; } + public string? Description { get; set; } + public bool AllowMultiple { get; set; } = false; + public bool IsRequired { get; set; } = false; + + public ArgumentAttribute(int order) + { + Order = order; + } + } +} diff --git a/ebuild.cli/CliParser.cs b/ebuild.cli/CliParser.cs new file mode 100644 index 0000000..287f4dc --- /dev/null +++ b/ebuild.cli/CliParser.cs @@ -0,0 +1,925 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Microsoft.Extensions.Logging; +using System.Threading; +using System.Threading.Tasks; + +namespace ebuild.cli +{ + /// + /// CLI Parser for commands and options. + /// Parse-first two-pass parser: tokenization then mapping. + /// + public enum DuplicateOptionPolicy + { + Error, + Warn, + Ignore + } + + public enum UnknownOptionPolicy + { + Error, + Warn, + Ignore + } + + public class CliParser + { + private readonly ILogger? _logger; + public Type? RootCommandType { get; } + public Command currentCommand { get; private set; } + public LinkedList currentCommandChain { get; } = new LinkedList(); + + public struct ParsedOption + { + public string Name { get; set; } + public string? Value { get; set; } + public LinkedList CommandChain { get; set; } + } + + // Search the command hierarchy (root and its subcommands) for a field that declares + // the option `name` and has OptionAttribute.Global == true. Returns true when found + // and outputs the matching FieldInfo and Command instance. + private bool FindGlobalOptionField(string name, out FieldInfo? fieldOut, out Command? cmdOut) + { + fieldOut = null; + cmdOut = null; + var root = currentCommandChain.First!.Value; + + FieldInfo? foundField = null; + Command? foundCmd = null; + + bool Recurse(Command node) + { + foreach (var f in node.OptionFields) + { + var attr = f.GetCustomAttribute(); + if (attr == null) continue; + if (!attr.Global) continue; + if (!string.IsNullOrEmpty(attr.Name) && string.Equals(attr.Name, name, StringComparison.OrdinalIgnoreCase)) + { + foundField = f; + foundCmd = node; + return true; + } + if (!string.IsNullOrEmpty(attr.ShortName) && string.Equals(attr.ShortName, name, StringComparison.OrdinalIgnoreCase)) + { + foundField = f; + foundCmd = node; + return true; + } + if (f.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) + { + foundField = f; + foundCmd = node; + return true; + } + } + + foreach (var c in node.subCommands) + { + if (Recurse(c)) return true; + } + return false; + } + + var result = Recurse(root); + fieldOut = foundField; + cmdOut = foundCmd; + return result; + } + + public struct ParsedArgument + { + public string Value { get; set; } + public int Order { get; set; } + public LinkedList CommandChain { get; set; } + } + + private readonly List parsedOptions = new List(); + private readonly List parsedArguments = new List(); + + public IReadOnlyList ParsedOptions => parsedOptions; + public IReadOnlyList ParsedArguments => parsedArguments; + + private readonly DuplicateOptionPolicy duplicatePolicy; + private readonly UnknownOptionPolicy unknownOptionPolicy; + + public CliParser(Type? rootCommandType = null, DuplicateOptionPolicy duplicateOptionPolicy = DuplicateOptionPolicy.Warn, UnknownOptionPolicy unknownOptionPolicy = UnknownOptionPolicy.Warn, ILogger? logger = null) + { + RootCommandType = rootCommandType; + if (rootCommandType != null) + { + currentCommand = (Command)(Activator.CreateInstance(rootCommandType) ?? throw new InvalidOperationException($"Could not create instance of root command type {rootCommandType.FullName}.")); + } + else + { + // create a ghost root command when no root type is supplied + currentCommand = new Command(); + currentCommand.RuntimeName = ""; // unnamed ghost root + } + currentCommandChain.AddLast(currentCommand); + duplicatePolicy = duplicateOptionPolicy; + this.unknownOptionPolicy = unknownOptionPolicy; + _logger = logger; + } + + // When help is provided as the first argument (`prog help a b`), store the target tokens + // so help resolution can occur later (after commands are registered). + private List? pendingHelpTargets; + + /// + /// Scan an assembly for types deriving from `Command` and register them under the parser's root command, + /// placing multi-part named commands under their parent according to space-separated name parts. + /// Types with `CommandAttribute.AutoRegister == false` are skipped. + /// + public void RegisterCommandsFromAssembly(Assembly assembly) + { + if (assembly == null) throw new ArgumentNullException(nameof(assembly)); + + var types = assembly.GetTypes().Where(t => typeof(Command).IsAssignableFrom(t) && !t.IsAbstract).ToList(); + + // Build map fullName -> Type, skipping types that opted out via CommandAttribute.AutoRegister == false + var nameToType = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var t in types) + { + var attr = t.GetCustomAttribute(); + if (attr != null && attr.AutoRegister == false) continue; + var fullName = (attr != null && !string.IsNullOrEmpty(attr.Name)) ? attr.Name : t.Name; + if (!nameToType.ContainsKey(fullName)) + nameToType[fullName] = t; + else + LogWarning($"Duplicate command name '{fullName}' found in assembly {assembly.FullName}. Skipping type {t.FullName}."); + } + + // Instances created so far by fullName + var instances = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // root canonical name + var rootAttr = currentCommand.GetType().GetCustomAttribute(); + var rootFullName = !string.IsNullOrEmpty(currentCommand.RuntimeName) ? currentCommand.RuntimeName : (rootAttr != null && !string.IsNullOrEmpty(rootAttr.Name) ? rootAttr.Name : currentCommand.GetType().Name); + instances[rootFullName] = currentCommand; + + // Process in order of increasing name-part length so parents are created before children + foreach (var kv in nameToType.OrderBy(k => k.Key.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries).Length)) + { + var fullName = kv.Key; + var t = kv.Value; + // Skip the root type if present + if (string.Equals(fullName, rootFullName, StringComparison.OrdinalIgnoreCase)) continue; + + var parts = fullName.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + string parentFullName; + if (parts.Length == 1) + parentFullName = rootFullName; + else + parentFullName = string.Join(' ', parts.Take(parts.Length - 1)); + + if (!instances.TryGetValue(parentFullName, out var parent)) + { + // create missing parent chain (ghost commands) so help can be displayed + var parentParts = parentFullName.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + string accum = string.Empty; + Command? lastParent = currentCommand; + for (int pi = 0; pi < parentParts.Length; pi++) + { + accum = pi == 0 ? parentParts[pi] : accum + " " + parentParts[pi]; + if (instances.TryGetValue(accum, out var existing)) + { + lastParent = existing; + continue; + } + + // If there is a concrete type for this prefix, instantiate it; otherwise create a ghost Command + if (nameToType.TryGetValue(accum, out var concreteType)) + { + try + { + var instConcrete = Activator.CreateInstance(concreteType) as Command; + if (instConcrete != null) + { + lastParent.AddSubCommand(instConcrete); + instances[accum] = instConcrete; + lastParent = instConcrete; + continue; + } + } + catch + { + // fall through to ghost + } + } + + var ghost = new Command(); + ghost.RuntimeName = accum; + lastParent.AddSubCommand(ghost); + instances[accum] = ghost; + lastParent = ghost; + } + + parent = instances[parentFullName]; + } + + try + { + var inst = Activator.CreateInstance(t) as Command; + if (inst == null) + { + LogWarning($"Could not instantiate command type {t.FullName}"); + continue; + } + + parent.AddSubCommand(inst); + instances[fullName] = inst; + } + catch (Exception ex) + { + LogWarning($"Failed to create command {t.FullName}: {ex.Message}"); + } + } + } + + public void Parse(string[] args) + { + parsedOptions.Clear(); + parsedArguments.Clear(); + + int argOrder = 0; + bool endOfOptions = false; + + // Special-case: support `help` as the first argument: `help a b` -> show help for `a b`. + // Defer resolution so command registration can occur after Parse. + if (args.Length > 0) + { + var first = args[0]; + if (string.Equals(first, "help", StringComparison.OrdinalIgnoreCase) || string.Equals(first, "h", StringComparison.OrdinalIgnoreCase)) + { + pendingHelpTargets = new List(); + for (int j = 1; j < args.Length; j++) + pendingHelpTargets.Add(args[j]); + LogInfo($"Help requested for: {string.Join(' ', pendingHelpTargets)}"); + return; // stop normal parsing; resolution will happen at execution time + } + } + + // Iterate over all provided args (Main(string[] args) does not include program name) + for (int i = 0; i < args.Length; i++) + { + var raw = args[i]; + if (raw == null) continue; + + // Check for subcommand first (literal name match) + var subCommand = currentCommand.FindSubCommand(raw); + if (subCommand != null) + { + currentCommand = subCommand; + currentCommandChain.AddLast(currentCommand); + continue; + } + + if (!endOfOptions && raw == "--") + { + endOfOptions = true; + continue; + } + + if (!endOfOptions) + { + if (IsNegativeNumber(raw) && !HasMatchingOptionForToken(raw)) + { + parsedArguments.Add(new ParsedArgument { Value = raw, Order = argOrder++, CommandChain = new LinkedList(currentCommandChain) }); + continue; + } + + if (IsOptionToken(raw)) + { + var opt = ParseOptionToken(args, ref i); + if (opt.HasValue) + { + var o = opt.Value; + o.Value = Unquote(o.Value); + o.CommandChain = new LinkedList(currentCommandChain); + parsedOptions.Add(o); + continue; + } + } + } + + // Otherwise positional argument + parsedArguments.Add(new ParsedArgument { Value = raw, Order = argOrder++, CommandChain = new LinkedList(currentCommandChain) }); + } + } + + private bool HasMatchingOptionForToken(string token) + { + // token starts with '-' + var stripped = token.TrimStart('-'); + // Check current command's option fields for Name or ShortName matching + var fields = currentCommand.OptionFields; + foreach (var f in fields) + { + var attr = f.GetCustomAttribute(); + if (attr == null) continue; + if (!string.IsNullOrEmpty(attr.Name) && string.Equals(attr.Name, stripped, StringComparison.OrdinalIgnoreCase)) return true; + if (!string.IsNullOrEmpty(attr.ShortName) && string.Equals(attr.ShortName, stripped, StringComparison.OrdinalIgnoreCase)) return true; + // also allow single-char short without attribute as field name match + if (f.Name.Equals(stripped, StringComparison.OrdinalIgnoreCase)) return true; + } + return false; + } + + private bool IsOptionToken(string token) + { + if (string.IsNullOrEmpty(token)) return false; + if (!token.StartsWith("-")) return false; + // token is option-like (starts with -), but we will not expand combined short flags. + return true; + } + + private bool HasShortOption(string shortName) + { + if (string.IsNullOrEmpty(shortName)) return false; + var fields = currentCommand.OptionFields; + foreach (var f in fields) + { + var attr = f.GetCustomAttribute(); + if (attr == null) continue; + if (!string.IsNullOrEmpty(attr.ShortName) && string.Equals(attr.ShortName, shortName, StringComparison.OrdinalIgnoreCase)) return true; + if (f.Name.Equals(shortName, StringComparison.OrdinalIgnoreCase)) return true; + } + return false; + } + + private ParsedOption? ParseOptionToken(string token, IEnumerator enumerator) + { + // Long option: --name or --name=value + if (token.StartsWith("--")) + { + var body = token.Substring(2); + var idx = body.IndexOf('='); + if (idx >= 0) + { + var name = body.Substring(0, idx); + var val = body.Substring(idx + 1); + return new ParsedOption { Name = name, Value = val }; + } + else + { + // Take next token as value if it doesn't start with - + var name = body; + return new ParsedOption { Name = name, Value = null }; + } + } + + // Short-ish option: -x or -name (we disallow combined expansion -abc) + if (token.StartsWith("-")) + { + var body = token.Substring(1); + var idx = body.IndexOf('='); + if (idx >= 0) + { + var name = body.Substring(0, idx); + var val = body.Substring(idx + 1); + return new ParsedOption { Name = name, Value = val }; + } + // If body length==1 treat as short flag possibly with separated value + if (body.Length == 1) + { + var name = body; + return new ParsedOption { Name = name, Value = null }; + } + else + { + // body longer than 1 and no '=' -> treat as single option name (disallow combined short flags) + return new ParsedOption { Name = body, Value = null }; + } + } + + return null; + } + + // index-based overload used by the Parse loop so we can consume following tokens when needed + private ParsedOption? ParseOptionToken(string[] args, ref int i) + { + var token = args[i]; + if (token.StartsWith("--")) + { + var body = token.Substring(2); + var idx = body.IndexOf('='); + if (idx >= 0) + { + var name = body.Substring(0, idx); + var val = body.Substring(idx + 1); + return new ParsedOption { Name = name, Value = val }; + } + else + { + var name = body; + if (i + 1 < args.Length && !args[i + 1].StartsWith("-")) + { + i++; + return new ParsedOption { Name = name, Value = args[i] }; + } + return new ParsedOption { Name = name, Value = null }; + } + } + + if (token.StartsWith("-")) + { + var body = token.Substring(1); + var idx = body.IndexOf('='); + if (idx >= 0) + { + // Prefer short-name semantics for -DKey=Value => short name 'D', value 'Key=Value' when possible + var candidateShort = body.Substring(0, 1); + if (HasShortOption(candidateShort)) + { + var name = candidateShort; + var val = body.Substring(1); // keep the rest (including any '=') + return new ParsedOption { Name = name, Value = val }; + } + + var nameLong = body.Substring(0, idx); + var valLong = body.Substring(idx + 1); + return new ParsedOption { Name = nameLong, Value = valLong }; + } + + if (body.Length == 1) + { + var name = body; + if (i + 1 < args.Length && !args[i + 1].StartsWith("-")) + { + i++; + return new ParsedOption { Name = name, Value = args[i] }; + } + return new ParsedOption { Name = name, Value = null }; + } + else + { + // treat as -Dkey (short name + attached value) when first char matches a short option + var candidateShort = body.Substring(0, 1); + if (HasShortOption(candidateShort)) + { + var name = candidateShort; + var val = body.Substring(1); + return new ParsedOption { Name = name, Value = val }; + } + return new ParsedOption { Name = body, Value = null }; + } + } + + return null; + } + + private static string? Unquote(string? s) + { + if (s == null) return null; + if (s.Length >= 2 && ((s.StartsWith("\"") && s.EndsWith("\"")) || (s.StartsWith("'") && s.EndsWith("'")))) + return s.Substring(1, s.Length - 2); + return s; + } + + private static bool IsNegativeNumber(string token) + { + // simple heuristic: -123 or -123.45 + if (string.IsNullOrEmpty(token)) return false; + if (!token.StartsWith("-")) return false; + var rest = token.Substring(1); + return double.TryParse(rest, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out _); + } + + // Mapping pass: map parsedOptions/parsedArguments to command fields and run converters + public void ApplyParsedToCommands() + { + var optionGroups = parsedOptions.GroupBy(p => p.Name, StringComparer.OrdinalIgnoreCase); + + foreach (var group in optionGroups) + { + var name = group.Key; + FieldInfo? targetField = null; + Command? targetCommandInstance = null; + + foreach (var cmd in currentCommandChain.Reverse()) + { + var fields = cmd.OptionFields; + foreach (var f in fields) + { + var attr = f.GetCustomAttribute(); + if (attr == null) continue; + if (!string.IsNullOrEmpty(attr.Name) && string.Equals(attr.Name, name, StringComparison.OrdinalIgnoreCase)) + { + targetField = f; + targetCommandInstance = cmd; + break; + } + if (!string.IsNullOrEmpty(attr.ShortName) && string.Equals(attr.ShortName, name, StringComparison.OrdinalIgnoreCase)) + { + targetField = f; + targetCommandInstance = cmd; + break; + } + if (f.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) + { + targetField = f; + targetCommandInstance = cmd; + break; + } + } + if (targetField != null) break; + } + + if (targetField == null) + { + // If not found in the current command chain, check for a globally-declared option + var globalFound = FindGlobalOptionField(name, out FieldInfo? globalField, out Command? globalCmd); + if (globalFound) + { + targetField = globalField; + targetCommandInstance = globalCmd; + } + + if (targetField == null) + { + switch (unknownOptionPolicy) + { + case UnknownOptionPolicy.Error: + throw new InvalidOperationException($"Unknown option: {name}"); + case UnknownOptionPolicy.Warn: + LogWarning($"Unknown option '{name}' for command {currentCommand.GetType().Name}. Ignoring."); + break; + case UnknownOptionPolicy.Ignore: + break; + } + continue; + } + } + + var attrOpt = targetField.GetCustomAttribute()!; + var values = group.Select(g => g.Value).ToList(); + + if (OptionAttribute.IsFlag(targetField)) + { + var lastVal = values.LastOrDefault(); + object finalBool = true; + if (!string.IsNullOrEmpty(lastVal)) + { + if (!bool.TryParse(lastVal, out var b)) + throw new InvalidOperationException($"Could not parse boolean for option {name}: '{lastVal}'"); + finalBool = b; + } + targetField.SetValue(targetCommandInstance, finalBool); + continue; + } + + if (OptionAttribute.IsMultiple(targetField) && !OptionAttribute.IsDictionary(targetField)) + { + var fieldVal = targetField.GetValue(targetCommandInstance); + var fType = targetField.FieldType; + if (fieldVal == null) + { + if (fType.IsInterface && fType.IsGenericType) + { + var elemType = fType.GetGenericArguments()[0]; + var listType = typeof(List<>).MakeGenericType(elemType); + fieldVal = Activator.CreateInstance(listType); + targetField.SetValue(targetCommandInstance, fieldVal); + } + } + + if (fieldVal is System.Collections.IList list) + { + var elemType = targetField.FieldType.IsArray ? targetField.FieldType.GetElementType() : targetField.FieldType.IsGenericType ? targetField.FieldType.GetGenericArguments()[0] : typeof(object); + foreach (var v in values) + { + var conv = ConvertStringToType(v, elemType!, attrOpt.ConverterType); + list.Add(conv); + } + continue; + } + + throw new InvalidOperationException($"Cannot append values to field {targetField.Name}"); + } + + if (OptionAttribute.IsDictionary(targetField)) + { + var fieldVal = targetField.GetValue(targetCommandInstance); + var fType = targetField.FieldType; + Type keyType; + Type valType; + + if (fieldVal == null) + { + if (fType.IsInterface && fType.IsGenericType) + { + var args = fType.GetGenericArguments(); + keyType = args[0]; + valType = args[1]; + var dictType = typeof(Dictionary<,>).MakeGenericType(keyType, valType); + fieldVal = Activator.CreateInstance(dictType); + targetField.SetValue(targetCommandInstance, fieldVal); + } + else if (fType.IsGenericType) + { + var args = fType.GetGenericArguments(); + keyType = args[0]; + valType = args[1]; + var dictType = typeof(Dictionary<,>).MakeGenericType(keyType, valType); + fieldVal = Activator.CreateInstance(dictType); + targetField.SetValue(targetCommandInstance, fieldVal); + } + else + { + throw new InvalidOperationException($"Cannot create dictionary for field {targetField.Name}"); + } + } + + if (fieldVal is System.Collections.IDictionary dict) + { + var genArgs = fieldVal.GetType().IsGenericType ? fieldVal.GetType().GetGenericArguments() : fType.IsGenericType ? fType.GetGenericArguments() : new Type[] { typeof(string), typeof(string) }; + keyType = genArgs.Length > 0 ? genArgs[0] : typeof(string); + valType = genArgs.Length > 1 ? genArgs[1] : typeof(string); + + foreach (var v in values) + { + if (string.IsNullOrEmpty(v)) continue; + var idx = v.IndexOf('='); + if (idx <= 0) throw new InvalidOperationException($"Invalid dictionary entry for option {name}: '{v}'"); + var keyStr = v.Substring(0, idx); + var valStr = v.Substring(idx + 1); + var keyObj = ConvertStringToType(keyStr, keyType, null); + var valObj = ConvertStringToType(valStr, valType, attrOpt.ConverterType); + dict[keyObj!] = valObj!; + } + continue; + } + + throw new InvalidOperationException($"Cannot assign dictionary values to field {targetField.Name}"); + } + + if (values.Count > 1) + { + if (duplicatePolicy == DuplicateOptionPolicy.Error) + throw new InvalidOperationException($"Multiple occurrences of option '{name}' not allowed."); + if (duplicatePolicy == DuplicateOptionPolicy.Warn) + LogWarning($"Multiple occurrences of option '{name}' for command {currentCommand.GetType().Name} — using last value."); + } + + var last = values.LastOrDefault(); + var converted = ConvertStringToType(last, targetField.FieldType, attrOpt.ConverterType); + targetField.SetValue(targetCommandInstance, converted); + } + + // Map positional arguments using ArgumentAttribute + var argsByCommand = parsedArguments.GroupBy(a => a.CommandChain.Last!.Value); + foreach (var grp in argsByCommand) + { + var cmd = grp.Key; + var argList = grp.OrderBy(a => a.Order).Select(a => a.Value).ToList(); + var fields = cmd.GetType().GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .Select(f => new { Field = f, Attr = f.GetCustomAttribute() }) + .Where(x => x.Attr != null) + .OrderBy(x => x.Attr!.Order) + .ToList(); + + int ai = 0; + foreach (var entry in fields) + { + var f = entry.Field; + var attr = entry.Attr!; + if (attr.AllowMultiple) + { + // assign the rest + var remaining = argList.Skip(ai).ToList(); + if (remaining.Count == 0 && attr.IsRequired) + throw new InvalidOperationException($"Missing required positional argument '{attr.Name ?? f.Name}' for command {cmd.GetType().Name}"); + + if (remaining.Count > 0) + { + if (OptionAttribute.IsMultiple(f) || typeof(System.Collections.IList).IsAssignableFrom(f.FieldType)) + { + var elemType = f.FieldType.IsArray ? f.FieldType.GetElementType() : f.FieldType.IsGenericType ? f.FieldType.GetGenericArguments()[0] : typeof(string); + var listType = typeof(List<>).MakeGenericType(elemType!); + var list = (System.Collections.IList?)Activator.CreateInstance(listType); + foreach (var v in remaining) + { + list!.Add(ConvertStringToType(v, elemType!, null)); + } + f.SetValue(cmd, list); + } + else + { + throw new InvalidOperationException($"Field {f.Name} is not a collection but marked AllowMultiple"); + } + } + ai = argList.Count; // consumed all + break; + } + else + { + if (ai >= argList.Count) + { + if (attr.IsRequired) + throw new InvalidOperationException($"Missing required positional argument '{attr.Name ?? f.Name}' for command {cmd.GetType().Name}"); + break; + } + var val = argList[ai++]; + var conv = ConvertStringToType(val, f.FieldType, null); + f.SetValue(cmd, conv); + } + } + } + } + + /// + /// Execute the current command (the last in the current command chain). + /// If the command is the `help` command, render help for the parent command instead. + /// Returns an integer exit code (0 success). + /// + public async Task ExecuteCurrentCommandAsync(CancellationToken cancellationToken = default) + { + // If help was requested as the first argument, resolve the desired command chain now + if (pendingHelpTargets != null) + { + // build chain from root using registered commands + var chain = new LinkedList(); + chain.AddLast(currentCommandChain.First!.Value); + foreach (var tok in pendingHelpTargets) + { + var sub = chain.Last!.Value.FindSubCommand(tok); + if (sub == null) break; + chain.AddLast(sub); + } + // append HelpCommand + chain.AddLast(new HelpCommand()); + + LogInfo($"Resolved help chain: {string.Join(" -> ", chain.Select(c => !string.IsNullOrEmpty(c.RuntimeName) ? c.RuntimeName : (c.GetType().GetCustomAttribute()?.Name ?? c.GetType().Name)))}"); + currentCommandChain.Clear(); + foreach (var c in chain) currentCommandChain.AddLast(c); + currentCommand = currentCommandChain.Last!.Value; + pendingHelpTargets = null; + } + + var last = currentCommandChain.Last!.Value; + // If root is a ghost root (unnamed) and no subcommand was provided, show help + if (currentCommandChain.Count == 1 && string.IsNullOrEmpty(currentCommandChain.First!.Value.RuntimeName)) + { + RenderHelpForCommand(currentCommandChain.First!.Value); + return 0; + } + + if (last is HelpCommand) + { + var parentNode = currentCommandChain.Last.Previous; + var parent = parentNode != null ? parentNode.Value : currentCommandChain.First!.Value; + RenderHelpForCommand(parent); + return 0; + } + + // call command's execution method + try + { + return await last.ExecuteAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + LogWarning($"Command execution failed: {ex.Message}"); + return 1; + } + } + + private void RenderHelpForCommand(Command cmd) + { + void Write(string s) + { + if (_logger != null) _logger.LogInformation(s); + else Console.WriteLine(s); + } + + Write($"Usage: {cmd.GetType().Name}"); + + // Subcommands + var subs = cmd.GetType().GetFields(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance) + .Where(f => false); // placeholder to keep formatting consistent + Write("\nCommands:"); + foreach (var s in cmd.subCommands) + { + var attr = s.GetType().GetCustomAttribute(); + var name = !string.IsNullOrEmpty(s.RuntimeName) ? s.RuntimeName! : (attr != null && !string.IsNullOrEmpty(attr.Name) ? attr.Name : s.GetType().Name); + var aliasesArr = s.RuntimeAliases ?? attr?.Aliases; + var aliases = aliasesArr != null && aliasesArr.Length > 0 ? $" (aliases: {string.Join(",", aliasesArr)})" : string.Empty; + var desc = !string.IsNullOrEmpty(s.RuntimeDescription) ? $" - {s.RuntimeDescription}" : (attr != null && !string.IsNullOrEmpty(attr.Description) ? $" - {attr.Description}" : string.Empty); + Write($" {name}{aliases}{desc}"); + } + + // Options + Write("\nOptions:"); + var opts = cmd.OptionFields; + foreach (var f in opts) + { + var a = f.GetCustomAttribute(); + if (a == null) continue; + var longName = !string.IsNullOrEmpty(a.Name) ? a.Name : f.Name; + var shortName = !string.IsNullOrEmpty(a.ShortName) ? $", -{a.ShortName}" : string.Empty; + var desc = !string.IsNullOrEmpty(a.Description) ? $" - {a.Description}" : string.Empty; + Write($" --{longName}{shortName} : {GetFriendlyTypeName(f.FieldType)}{desc}"); + } + + // Arguments + Write("\nArguments:"); + var argFields = cmd.GetType().GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .Select(f => new { Field = f, Attr = f.GetCustomAttribute() }) + .Where(x => x.Attr != null) + .OrderBy(x => x.Attr!.Order); + foreach (var entry in argFields) + { + var attr = entry.Attr!; + var desc = !string.IsNullOrEmpty(attr.Description) ? $" - {attr.Description}" : string.Empty; + Write($" {attr.Order}: {attr.Name ?? entry.Field.Name} ({GetFriendlyTypeName(entry.Field.FieldType)}){(attr.IsRequired ? " [required]" : string.Empty)}{desc}"); + } + } + + private string GetFriendlyTypeName(Type t) + { + if (t.IsArray) + { + var elem = GetFriendlyTypeName(t.GetElementType()!); + return elem + "[]"; + } + + var nonNullable = Nullable.GetUnderlyingType(t) ?? t; + if (nonNullable.IsGenericType) + { + var genDef = nonNullable.GetGenericTypeDefinition(); + var name = genDef.Name; + var backTick = name.IndexOf('`'); + if (backTick >= 0) name = name.Substring(0, backTick); + var args = nonNullable.GetGenericArguments().Select(a => GetFriendlyTypeName(a)); + return name + "<" + string.Join(", ", args) + ">"; + } + + return nonNullable.Name; + } + + private object? ConvertStringToType(string? value, Type targetType, Type? converterType) + { + if (value == null) + { + if (Nullable.GetUnderlyingType(targetType) != null || targetType.IsClass) return null; + throw new InvalidOperationException($"Cannot assign null to non-nullable type {targetType}"); + } + + if (converterType != null) + { + var inst = Activator.CreateInstance(converterType) ?? throw new InvalidOperationException($"Could not create converter {converterType}"); + if (inst is IStringConverter conv) + { + return conv.Convert(value); + } + throw new InvalidOperationException($"Converter {converterType} does not implement IStringConverter"); + } + + var nonNullable = Nullable.GetUnderlyingType(targetType) ?? targetType; + if (nonNullable == typeof(string)) return value; + if (nonNullable.IsEnum) return Enum.Parse(nonNullable, value, true); + if (nonNullable == typeof(bool)) return bool.Parse(value); + if (nonNullable == typeof(int)) return int.Parse(value, System.Globalization.CultureInfo.InvariantCulture); + if (nonNullable == typeof(long)) return long.Parse(value, System.Globalization.CultureInfo.InvariantCulture); + if (nonNullable == typeof(double)) return double.Parse(value, System.Globalization.CultureInfo.InvariantCulture); + if (nonNullable == typeof(decimal)) return decimal.Parse(value, System.Globalization.CultureInfo.InvariantCulture); + if (nonNullable == typeof(Guid)) return Guid.Parse(value); + // Fallback to ChangeType + if (typeof(IConvertible).IsAssignableFrom(nonNullable)) + return Convert.ChangeType(value, nonNullable, System.Globalization.CultureInfo.InvariantCulture); + + throw new InvalidOperationException($"No conversion available for type {targetType}"); + } + + private void LogWarning(string message) + { + if (_logger != null) + { + _logger.LogWarning(message); + } + else + { + Console.WriteLine("Warning: " + message); + } + } + + private void LogInfo(string message) + { + if (_logger != null) + { + _logger.LogInformation(message); + } + else + { + Console.WriteLine(message); + } + } + } +} \ No newline at end of file diff --git a/ebuild.cli/Command.cs b/ebuild.cli/Command.cs new file mode 100644 index 0000000..b2673b2 --- /dev/null +++ b/ebuild.cli/Command.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +namespace ebuild.cli +{ + public class Command + { + // Optional runtime overrides for name/aliases/description used for ghost commands + public string? RuntimeName { get; set; } + public string[]? RuntimeAliases { get; set; } + public string? RuntimeDescription { get; set; } + internal FieldInfo[] OptionFields => fieldInfosCache ??= GetOptionFields(); + + internal HashSet subCommands { get; } = new HashSet(); + + public Command() + { + RegisterNestedSubcommands(); + } + + private void RegisterNestedSubcommands() + { + var nested = GetType().GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic); + foreach (var t in nested) + { + var attr = t.GetCustomAttribute(); + if (attr != null && attr.AutoRegister == false) continue; + if (!typeof(Command).IsAssignableFrom(t)) continue; + if (t.IsAbstract) continue; + try + { + // If the nested command declares a multi-part name, only register it under the matching parent + if (attr != null && !string.IsNullOrEmpty(attr.Name) && attr.Name.Contains(' ')) + { + var parts = attr.Name.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 2) + { + var parentParts = parts.Take(parts.Length - 1).ToArray(); + var myParts = GetCanonicalNameParts(this); + if (!myParts.SequenceEqual(parentParts, StringComparer.OrdinalIgnoreCase)) + { + continue; // not intended for this parent + } + } + } + + var inst = Activator.CreateInstance(t) as Command; + if (inst != null) + AddSubCommand(inst); + } + catch + { + // ignore types that cannot be constructed + } + } + } + + public void AddSubCommand(Command subCommand) + { + subCommands.Add(subCommand); + } + + public Command? FindSubCommand(string name) + { + // Prefer attribute-based name resolution if available + return subCommands.Where(c => + { + // prefer runtime name if present + if (!string.IsNullOrEmpty(c.RuntimeName)) + { + var parts = c.RuntimeName!.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + var local = parts.Length > 0 ? parts[^1] : c.RuntimeName; + if (string.Equals(local, name, StringComparison.OrdinalIgnoreCase)) return true; + if (c.RuntimeAliases != null && c.RuntimeAliases.Any(a => + { + var ap = a.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + var al = ap.Length > 0 ? ap[^1] : a; + return string.Equals(al, name, StringComparison.OrdinalIgnoreCase); + })) return true; + } + + var attr = c.GetType().GetCustomAttribute(); + if (attr != null && !string.IsNullOrEmpty(attr.Name)) + { + var parts = attr.Name.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + var local = parts.Length > 0 ? parts[^1] : attr.Name; + if (string.Equals(local, name, StringComparison.OrdinalIgnoreCase)) return true; + if (attr.Aliases != null && attr.Aliases.Any(a => + { + var ap = a.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + var al = ap.Length > 0 ? ap[^1] : a; + return string.Equals(al, name, StringComparison.OrdinalIgnoreCase); + })) return true; + } + + // fallback: type name + return c.GetType().Name.Equals(name, StringComparison.OrdinalIgnoreCase); + }).FirstOrDefault(); + } + + private static string[] GetCanonicalNameParts(Command cmd) + { + if (!string.IsNullOrEmpty(cmd.RuntimeName)) + return cmd.RuntimeName!.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + + var attr = cmd.GetType().GetCustomAttribute(); + if (attr != null && !string.IsNullOrEmpty(attr.Name)) + { + return attr.Name.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + } + return new[] { cmd.GetType().Name }; + } + + private FieldInfo[] GetOptionFields() + { + var fields = GetType().GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + return fields.Where(f => f.GetCustomAttribute() != null).ToArray(); + } + + private FieldInfo[]? fieldInfosCache; + + /// + /// Asynchronously execute the command. Override in derived commands to perform work. + /// Returns an integer exit code; 0 indicates success. + /// + public virtual Task ExecuteAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(0); + } + } +} \ No newline at end of file diff --git a/ebuild.cli/CommandAttribute.cs b/ebuild.cli/CommandAttribute.cs new file mode 100644 index 0000000..4753894 --- /dev/null +++ b/ebuild.cli/CommandAttribute.cs @@ -0,0 +1,18 @@ +using System; + +namespace ebuild.cli +{ + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] + public class CommandAttribute : Attribute + { + public string Name { get; } + public string[]? Aliases { get; set; } + public string? Description { get; set; } + public bool AutoRegister { get; set; } = true; + + public CommandAttribute(string name) + { + Name = name; + } + } +} diff --git a/ebuild.cli/HelpCommand.cs b/ebuild.cli/HelpCommand.cs new file mode 100644 index 0000000..293f869 --- /dev/null +++ b/ebuild.cli/HelpCommand.cs @@ -0,0 +1,10 @@ +using System; + +namespace ebuild.cli +{ + [Command("help", Aliases = new[] { "h" })] + public class HelpCommand : Command + { + // marker command - behavior implemented by CliParser + } +} diff --git a/ebuild.cli/IStringConverter.cs b/ebuild.cli/IStringConverter.cs new file mode 100644 index 0000000..a078dd1 --- /dev/null +++ b/ebuild.cli/IStringConverter.cs @@ -0,0 +1,11 @@ +namespace ebuild.cli +{ + /// + /// Converter interface for converting a string into a value used by option/argument fields. + /// Implement custom converters by implementing this interface. + /// + public interface IStringConverter + { + object Convert(string value); + } +} diff --git a/ebuild.cli/OptionAttribute.cs b/ebuild.cli/OptionAttribute.cs new file mode 100644 index 0000000..4c64c50 --- /dev/null +++ b/ebuild.cli/OptionAttribute.cs @@ -0,0 +1,50 @@ +using System.Reflection; + +namespace ebuild.cli; + +[AttributeUsage(AttributeTargets.Field, AllowMultiple = false)] +public class OptionAttribute : Attribute +{ + public string? Name { get; } + public string? ShortName { get; set; } + public string? Description { get; set; } + // When true, this option may be provided at any position and will be applied + // to the command that declares it (useful for root/global flags like verbose). + public bool Global { get; set; } = false; + public int MinimumCount { get; set; } = 0; + public int MaximumCount { get; set; } = int.MaxValue; + public Type? ConverterType { get; set; } = null; + public OptionAttribute() + { + Name = null; + } + + public OptionAttribute(string name) + { + Name = name; + } + + + public static bool IsFlag(FieldInfo fieldInfo) + { + return fieldInfo.FieldType == typeof(bool) || typeof(bool?).IsAssignableFrom(fieldInfo.FieldType); + } + + public static bool IsMultiple(FieldInfo fieldInfo) + { + if (IsDictionary(fieldInfo)) return false; + return typeof(System.Collections.IEnumerable).IsAssignableFrom(fieldInfo.FieldType) && fieldInfo.FieldType != typeof(string); + } + + public bool IsRequired(FieldInfo fieldInfo) + { + return !(IsMultiple(fieldInfo) && MinimumCount > 0) && !IsFlag(fieldInfo) && !(Nullable.GetUnderlyingType(fieldInfo.FieldType) != null || fieldInfo.FieldType.IsClass); + } + + public static bool IsDictionary(FieldInfo fieldInfo) + { + if (typeof(System.Collections.IDictionary).IsAssignableFrom(fieldInfo.FieldType)) return true; + // detect generic IDictionary + return fieldInfo.FieldType.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(System.Collections.Generic.IDictionary<,>)); + } +} \ No newline at end of file diff --git a/ebuild.cli/ebuild.cli.csproj b/ebuild.cli/ebuild.cli.csproj new file mode 100644 index 0000000..0557eaa --- /dev/null +++ b/ebuild.cli/ebuild.cli.csproj @@ -0,0 +1,12 @@ + + + + net8.0 + enable + enable + + + + + + diff --git a/ebuild.sln b/ebuild.sln index 7986804..96ad179 100644 --- a/ebuild.sln +++ b/ebuild.sln @@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ebuild.api", "ebuild.api\eb EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ebuild.Tests", "ebuild.Tests\ebuild.Tests.csproj", "{7692B2EE-0A86-48F5-A50C-728283575BF8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ebuild.cli", "ebuild.cli\ebuild.cli.csproj", "{F04C8896-560B-45E1-B251-690EB277FDB6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -25,5 +27,9 @@ Global {7692B2EE-0A86-48F5-A50C-728283575BF8}.Debug|Any CPU.Build.0 = Debug|Any CPU {7692B2EE-0A86-48F5-A50C-728283575BF8}.Release|Any CPU.ActiveCfg = Release|Any CPU {7692B2EE-0A86-48F5-A50C-728283575BF8}.Release|Any CPU.Build.0 = Release|Any CPU + {F04C8896-560B-45E1-B251-690EB277FDB6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F04C8896-560B-45E1-B251-690EB277FDB6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F04C8896-560B-45E1-B251-690EB277FDB6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F04C8896-560B-45E1-B251-690EB277FDB6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/ebuild/Commands/BaseCommand.cs b/ebuild/Commands/BaseCommand.cs index 20f0044..b5a76cc 100644 --- a/ebuild/Commands/BaseCommand.cs +++ b/ebuild/Commands/BaseCommand.cs @@ -1,23 +1,19 @@ -using CliFx; -using CliFx.Attributes; -using CliFx.Infrastructure; +using System.Threading; +using System.Threading.Tasks; +using ebuild.cli; -namespace ebuild.Commands; - - -public abstract class BaseCommand : ICommand +namespace ebuild.Commands { - [CommandOption("verbose", 'v', Description = "enable verbose logging")] - public bool Verbose { get; init; } = false; - - public BaseCommand() + public abstract class BaseCommand : Command { - } + [Option("verbose", ShortName = "v", Description = "enable verbose logging")] + public bool Verbose; - public virtual ValueTask ExecuteAsync(IConsole console) - { - if (Verbose) - EBuild.VerboseEnabled = true; - return ValueTask.CompletedTask; + public override Task ExecuteAsync(CancellationToken cancellationToken = default) + { + if (Verbose) + EBuild.VerboseEnabled = true; + return Task.FromResult(0); + } } } \ No newline at end of file diff --git a/ebuild/Commands/BuildCommand.cs b/ebuild/Commands/BuildCommand.cs index c5c8844..2f77111 100644 --- a/ebuild/Commands/BuildCommand.cs +++ b/ebuild/Commands/BuildCommand.cs @@ -1,7 +1,7 @@ using System.CommandLine; using System.Text.Json.Nodes; -using CliFx.Attributes; using ebuild.api; +using ebuild.cli; using ebuild.Modules.BuildGraph; @@ -11,28 +11,27 @@ namespace ebuild.Commands [Command("build", Description = "build the specified module")] public class BuildCommand : ModuleCreatingCommand { - - - [CommandOption("dry-run", 'n', Description = "perform a trial run with no actual building")] - public bool NoCompile { get; init; } = false; - [CommandOption("clean", Description = "clean build")] - public bool Clean { get; init; } = false; - [CommandOption("build-worker-count", 'p', Description = "the build worker count to use. Default is 1")] - public int ProcessCount { get; init; } = 1; + [Option("dry-run", ShortName = "n", Description = "perform a trial run with no actual building")] + public bool NoCompile = false; + [Option("clean", Description = "clean build")] + public bool Clean = false; + [Option("build-worker-count", ShortName = "p", Description = "the build worker count to use. Default is 1")] + public int ProcessCount = 1; - public override async ValueTask ExecuteAsync(CliFx.Infrastructure.IConsole console) + + public override async Task ExecuteAsync(CancellationToken cancellationToken = default) { - await base.ExecuteAsync(console); + await base.ExecuteAsync(cancellationToken); var moduleFile = (ModuleFile)ModuleInstancingParams.SelfModuleReference; var moduleInstance = (await moduleFile.CreateModuleInstance(ModuleInstancingParams)) ?? throw new Exception("Failed to create module instance"); var graph = new Graph(moduleInstance); var worker = graph.CreateWorker(); + worker.Clean = Clean; worker.MaxWorkerCount = ProcessCount; - //TODO: Clean build if specified so. - await (worker as IWorker).ExecuteAsync(); - + await (worker as IWorker).ExecuteAsync(cancellationToken); + return 0; } } } \ No newline at end of file diff --git a/ebuild/Commands/CheckCommand.cs b/ebuild/Commands/CheckCommand.cs index f415794..4a40b8f 100644 --- a/ebuild/Commands/CheckCommand.cs +++ b/ebuild/Commands/CheckCommand.cs @@ -1,86 +1,72 @@ -using CliFx.Attributes; -using CliFx.Exceptions; -using CliFx.Infrastructure; -using ebuild.Modules.BuildGraph; - +using ebuild.Modules.BuildGraph; +using ebuild.cli; namespace ebuild.Commands { - [Command("check", Description = "check the module health and relevant info")] - public class CheckCommand : ModuleCreatingCommand - { - public override async ValueTask ExecuteAsync(IConsole console) - { - await base.ExecuteAsync(console); - } - } - - [Command("check circular-dependencies", Description = "check the module for circular dependencies")] - public class CheckCircularDependencyCommand : CheckCommand + public class CheckCircularDependencyCommand : ModuleCreatingCommand { - public override async ValueTask ExecuteAsync(IConsole console) + public override async Task ExecuteAsync(CancellationToken cancellationToken = default) { - await base.ExecuteAsync(console); + await base.ExecuteAsync(cancellationToken); var file = (ModuleFile)ModuleInstancingParams.SelfModuleReference; - var buildGraph = await file.BuildOrGetBuildGraph(ModuleInstancingParams) ?? throw new CommandException(string.Format("Failed to get build graph for {0}", file.GetFilePath())); - + var buildGraph = await file.BuildOrGetBuildGraph(ModuleInstancingParams) ?? throw new Exception(string.Format("Failed to get build graph for {0}", file.GetFilePath())); + if (buildGraph.HasCircularDependency()) { var errorMsg = string.Format("Circular dependency detected in {0}\n{1}", file.GetFilePath(), buildGraph.GetCircularDependencyPathString()); - throw new CommandException(errorMsg); + throw new Exception(errorMsg); } else { - console.Output.WriteLine("No circular dependency detected in {0}", file.GetFilePath()); + Console.WriteLine("No circular dependency detected in {0}", file.GetFilePath()); } + return 0; } } [Command("check print-dependencies", Description = "print the module dependencies")] - public class CheckPrintDependenciesCommand : CheckCommand + public class CheckPrintDependenciesCommand : ModuleCreatingCommand { - public override async ValueTask ExecuteAsync(IConsole console) + public override async Task ExecuteAsync(CancellationToken cancellationToken = default) { - await base.ExecuteAsync(console); var moduleFile = (ModuleFile)ModuleInstancingParams.SelfModuleReference; var buildGraph = await moduleFile.BuildOrGetBuildGraph(ModuleInstancingParams); if (buildGraph == null) { - console.Error.WriteLine("Failed to get build graph for {0}", moduleFile.GetFilePath()); - return; + throw new Exception(string.Format("Failed to get build graph for {0}", moduleFile.GetFilePath())); } - - console.Output.WriteLine("Dependencies for {0}", moduleFile.GetFilePath()); - console.Output.WriteLine("\n{0}", GetDependencyGraphString(buildGraph.GetRootNode())); + Console.WriteLine("Dependencies for {0}", moduleFile.GetFilePath()); + Console.WriteLine("\n{0}", GetDependencyGraphString(buildGraph.GetRootNode())); + return 0; } private static string GetDependencyGraphString(Node node, int depth = 0) { - return GetDependencyGraphString(node, depth, new HashSet()); + return GetDependencyGraphString(node, depth, []); } private static string GetDependencyGraphString(Node node, int depth, HashSet visited) { var result = new System.Text.StringBuilder(); var indent = new string(' ', depth * 2); - + if (node is ModuleDeclarationNode moduleNode) { // Check if this node creates a circular dependency var isCircular = visited.Contains(node); var nodeName = isCircular ? $"{moduleNode.Module.Name} (circular dependency)" : moduleNode.Module.Name; result.AppendLine($"{indent}{nodeName}"); - + // If circular, don't traverse further to avoid infinite recursion if (isCircular) { return result.ToString(); } - + visited.Add(node); - + // Get module dependencies (other ModuleDeclarationNode children) var moduleDependencies = node.Children.Joined().OfType(); foreach (var child in moduleDependencies) @@ -88,7 +74,7 @@ private static string GetDependencyGraphString(Node node, int depth, HashSet ExecuteAsync(CancellationToken cancellationToken = default) { - await base.ExecuteAsync(console); var moduleFile = (ModuleFile)ModuleInstancingParams.SelfModuleReference; - var createdModule = await moduleFile.CreateModuleInstance(ModuleInstancingParams) ?? throw new CommandException("Failed to create module instance."); + var createdModule = await moduleFile.CreateModuleInstance(ModuleInstancingParams) ?? throw new Exception("Failed to create module instance."); var graph = (await moduleFile.BuildOrGetBuildGraph(ModuleInstancingParams))!; var worker = graph.CreateWorker(); worker.GlobalMetadata["compile_commands_module_registry"] = new Dictionary>(); if (!ShouldDoForDependencies) worker.GlobalMetadata["target_module"] = createdModule; - await (worker as IWorker).ExecuteAsync(console.RegisterCancellationHandler()); + await (worker as IWorker).ExecuteAsync(cancellationToken); (worker as IWorker).GlobalMetadata.TryGetValue("compile_commands_module_registry", out object? value); if (value is Dictionary> compileCommandsModuleRegistry) @@ -52,41 +37,42 @@ public override async ValueTask ExecuteAsync(IConsole console) var outputPath = Path.GetFullPath(OutFile, module.Context.ModuleDirectory.FullName); Directory.CreateDirectory(module.OutputDirectory); await File.WriteAllTextAsync(outputPath, JsonSerializer.Serialize(list, writeOptions)); - console.Output.WriteLine($"Generated {outputPath}"); + Console.WriteLine($"Generated {outputPath}"); } } + return 0; } private static JsonSerializerOptions writeOptions = new() { WriteIndented = true, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; } [Command("generate buildgraph", Description = "generate a representation of the build graph for the module. Write to stdout.")] - public class GenerateBuildGraphString : GenerateCommand + public class GenerateBuildGraphString : ModuleCreatingCommand { public enum Format { String, Html } - [CommandOption("format", 'f', Description = "the output format, string or json.")] - public Format OutputFormat { get; init; } = Format.String; - public override async ValueTask ExecuteAsync(IConsole console) + [Option("format", ShortName = "f", Description = "the output format, string or json.")] + public Format OutputFormat = Format.String; + public override async Task ExecuteAsync(CancellationToken cancellationToken = default) { - await base.ExecuteAsync(console); var moduleFile = (ModuleFile)ModuleInstancingParams.SelfModuleReference; - var createdModule = await moduleFile.CreateModuleInstance(ModuleInstancingParams) ?? throw new CommandException("Failed to create module instance."); + var createdModule = await moduleFile.CreateModuleInstance(ModuleInstancingParams) ?? throw new Exception("Failed to create module instance."); var graph = (await moduleFile.BuildOrGetBuildGraph(ModuleInstancingParams))!; switch (OutputFormat) { case Format.String: - console.Output.WriteLine(graph.CreateTreeString()); + Console.WriteLine(graph.CreateTreeString()); break; case Format.Html: - console.Output.WriteLine(graph.CreateTreeHtml()); + Console.WriteLine(graph.CreateTreeHtml()); break; default: - throw new CommandException($"Unsupported format: {OutputFormat}"); + throw new Exception($"Unsupported format: {OutputFormat}"); } + return 0; } } @@ -94,32 +80,29 @@ public override async ValueTask ExecuteAsync(IConsole console) [Command("generate module", Description = "generate a new module file or update the c# solution to include references to dependencies of the module.")] public class GenerateModuleCommand : BaseCommand { - override public async ValueTask ExecuteAsync(IConsole console) + override public async Task ExecuteAsync(CancellationToken cancellationToken = default) { - await base.ExecuteAsync(console); - var generator = FindGenerator(Template) ?? throw new CommandException($"Module file generator '{Template}' not found."); + var generator = FindGenerator(Template) ?? throw new Exception($"Module file generator '{Template}' not found."); if (!Update) generator.Generate(ModuleFile, Force, TemplateOptions); if (Update) { generator.UpdateSolution(ModuleFile); } + return 0; } - [CommandParameter(0, Description = "the module name to create. If not specified, the created file will be index.ebuild.cs", IsRequired = false)] - public string ModuleFile { get; init; } = "index.ebuild.cs"; - - [CommandOption("force", 'f', Description = "overwrite existing module file if it exists")] - public bool Force { get; init; } = false; - - [CommandOption("update", 'u', Description = "update the c# solution to include dependencies of the module")] - public bool Update { get; init; } = false; - - [CommandOption("template", 't', Description = "the module template to use when creating a new module file")] - public string Template { get; init; } = "default"; - [CommandOption("template-options", 'O', Description = "the options to pass into the module template, in key=value;key2=value2 format", Converter = typeof(OptionsArrayConverter))] - public OptionsArray TemplateOptions { get; init; } = new(); - IModuleFileGenerator FindGenerator(string Name) + [Argument(0, Description = "the module name to create. If not specified, the created file will be index.ebuild.cs", IsRequired = false)] + public string ModuleFile = "index.ebuild.cs"; + [Option("force", ShortName = "f", Description = "overwrite existing module file if it exists")] + public bool Force = false; + [Option("update", ShortName = "u", Description = "update the c# solution to include dependencies of the module")] + public bool Update = false; + [Option("template", ShortName = "t", Description = "the module template to use when creating a new module file")] + public string Template = "default"; + [Option("template-options", ShortName = "O", Description = "the options to pass into the module template, use multiple to pass in multiple options -OKey=Value or -OKey Value")] + public Dictionary TemplateOptions = new(); + static IModuleFileGenerator FindGenerator(string Name) { return ModuleFileGeneratorRegistry.Instance.GetAll().First(g => g.Name == Name); } diff --git a/ebuild/Commands/ModuleCreatingCommand.cs b/ebuild/Commands/ModuleCreatingCommand.cs index e915442..5a23ca0 100644 --- a/ebuild/Commands/ModuleCreatingCommand.cs +++ b/ebuild/Commands/ModuleCreatingCommand.cs @@ -1,58 +1,30 @@ using System.Runtime.InteropServices; -using CliFx.Attributes; -using CliFx.Extensibility; -using CliFx.Infrastructure; using ebuild.api.Toolchain; +using ebuild.cli; using ebuild.Platforms; namespace ebuild.Commands { - public struct OptionsArray() - { - public Dictionary Options { get; set; } = []; - - - public static implicit operator Dictionary(OptionsArray optionsArray) => optionsArray.Options; - public static implicit operator OptionsArray(Dictionary dictionary) => new() { Options = dictionary }; - } - public class OptionsArrayConverter : CliFx.Extensibility.BindingConverter - { - public override OptionsArray Convert(string? value) - { - if (string.IsNullOrEmpty(value)) - return new OptionsArray(); - var entries = value.Split(';'); - var dictionary = new Dictionary(); - foreach (var entry in entries) - { - var parts = entry.Split('=', 2); - if (parts.Length != 2) - throw new FormatException("Invalid dictionary entry format. Expected format: key=value"); - dictionary[parts[0]] = parts[1]; - } - return dictionary; - } - } - - public abstract class ModuleCreatingCommand : BaseCommand - { - [CommandParameter(0, Description = "the module file to build", IsRequired = false)] - public string ModuleFile { get; init; } = "."; - [CommandOption("configuration", 'c', Description = "the build configuration to use")] - public string Configuration { get; init; } = Config.Get().DefaultBuildConfiguration; - [CommandOption("toolchain", Description = "the toolchain to use")] - public string Toolchain { get; init; } = IToolchainRegistry.Get().GetDefaultToolchainName() ?? ""; - [CommandOption("additional-compiler-option", 'C', Description = "additional compiler options to pass into compiler")] - public string[] AdditionalCompilerOptions { get; init; } = []; - [CommandOption("additional-linker-option", 'L', Description = "additional linker options to pass into linker")] - public string[] AdditionalLinkerOptions { get; init; } = []; - [CommandOption("additional-dependency-path", 'P', Description = "additional paths to search dependency modules at")] - public string[] AdditionalDependencyPaths { get; init; } = []; - [CommandOption("target-architecture", 't', Description = "the target architecture to use")] - public Architecture TargetArchitecture { get; init; } = RuntimeInformation.OSArchitecture; - public string Platform { get; init; } = PlatformRegistry.GetHostPlatform().Name; - [CommandOption("option", 'D', Description = "the options to pass into module", Converter = typeof(OptionsArrayConverter))] - public OptionsArray Options { get; init; } = new OptionsArray(); + public abstract class ModuleCreatingCommand : Command + { + [Argument(0, Name="module-file", Description = "the module file to build", IsRequired = true)] + public string ModuleFile = "."; + [Option("configuration", ShortName = "c", Description = "the build configuration to use")] + public string Configuration = Config.Get().DefaultBuildConfiguration; + [Option("toolchain", Description = "the toolchain to use")] + public string Toolchain = IToolchainRegistry.Get().GetDefaultToolchainName() ?? ""; + [Option("additional-compiler-option", ShortName = "C", Description = "additional compiler options to pass into compiler")] + public string[] AdditionalCompilerOptions = []; + [Option("additional-linker-option", ShortName = "L", Description = "additional linker options to pass into linker")] + public string[] AdditionalLinkerOptions = []; + [Option("additional-dependency-path", ShortName = "P", Description = "additional paths to search dependency modules at")] + public string[] AdditionalDependencyPaths = []; + [Option("target-architecture", ShortName = "t", Description = "the target architecture to use")] + public Architecture TargetArchitecture = RuntimeInformation.OSArchitecture; + [Option("platform", ShortName = "m", Description = "the target platform to use")] + public string Platform = PlatformRegistry.GetHostPlatform().Name; + [Option("option", ShortName = "D", Description = "the options to pass into module")] + public Dictionary Options = []; public ModuleInstancingParams ModuleInstancingParams => _moduleInstancingParamsCache ??= new() { @@ -67,9 +39,5 @@ public abstract class ModuleCreatingCommand : BaseCommand SelfModuleReference = new api.ModuleReference(ModuleFile) }; private ModuleInstancingParams? _moduleInstancingParamsCache; - public override async ValueTask ExecuteAsync(IConsole console) - { - await base.ExecuteAsync(console); - } } } \ No newline at end of file diff --git a/ebuild/Commands/PropertyCommand.cs b/ebuild/Commands/PropertyCommand.cs index 6350a97..acb9e42 100644 --- a/ebuild/Commands/PropertyCommand.cs +++ b/ebuild/Commands/PropertyCommand.cs @@ -1,37 +1,24 @@ -using CliFx; -using CliFx.Attributes; -using CliFx.Exceptions; -using CliFx.Infrastructure; +using ebuild.cli; namespace ebuild.Commands { - [Command("property", Description = "operations for properties. These are useful for creation of custom scripts or using ebuild without referencing, directly from command line")] - public class PropertyCommand : BaseCommand - { - - public override async ValueTask ExecuteAsync(IConsole console) - { - await base.ExecuteAsync(console); - } - } - [Command("property get", Description = "get the value of a property")] - public class PropertyGetCommand : PropertyCommand + public class PropertyGetCommand : BaseCommand { - [CommandParameter(0, Description = "the name of the property to get")] - public string PropertyName { get; init; } = string.Empty; + [Argument(0, Description = "the name of the property to get")] + public string PropertyName = string.Empty; - public override async ValueTask ExecuteAsync(IConsole console) + public override async Task ExecuteAsync(CancellationToken cancellationToken = default) { - await base.ExecuteAsync(console); if (PropertyName == "ebuild.api.dll") { - console.Output.WriteLine(EBuild.FindEBuildApiDllPath()); + Console.WriteLine(EBuild.FindEBuildApiDllPath()); } else { - throw new CommandException($"Unknown property '{PropertyName}'"); + throw new Exception($"Unknown property '{PropertyName}'"); } + return 0; } } } \ No newline at end of file diff --git a/ebuild/Compilers/CompilerUtils.cs b/ebuild/Compilers/CompilerUtils.cs index d7e8dc8..d2598d5 100644 --- a/ebuild/Compilers/CompilerUtils.cs +++ b/ebuild/Compilers/CompilerUtils.cs @@ -11,55 +11,55 @@ public static string GetObjectOutputFolder(ModuleBase module) { if (module == null) throw new NullReferenceException("CurrentModule is null."); - + if (module.UseVariants) - return Path.Join(module.Context.ModuleDirectory!.FullName, ".ebuild", - ((ModuleFile)module.Context.SelfReference).Name, "build", + return Path.Join(module.Context.ModuleDirectory!.FullName, ".ebuild", + ((ModuleFile)module.Context.SelfReference).Name, "build", module.GetVariantId().ToString(), "obj") + Path.DirectorySeparatorChar; - - return Path.Join(module.Context.ModuleDirectory!.FullName, ".ebuild", - ((ModuleFile)module.Context.SelfReference).Name, "build", "obj") + Path.DirectorySeparatorChar; + + return Path.Join(module.Context.ModuleDirectory!.FullName, ".ebuild", + ((ModuleFile)module.Context.SelfReference).Name, "build", module.Context.Configuration, "obj") + Path.DirectorySeparatorChar; } public static List FindBuildArtifacts(ModuleBase module, bool includeObjectFiles = true, bool includePdbFiles = true, bool includeStaticLibraries = false, bool includeDynamicLibraries = false, bool includeExecutables = false) { var objectOutputFolder = GetObjectOutputFolder(module); - if(objectOutputFolder == null || !Directory.Exists(objectOutputFolder)) + if (objectOutputFolder == null || !Directory.Exists(objectOutputFolder)) { Logger.LogInformation("Object output folder {folder} does not exist", objectOutputFolder); return new List(); } var files = new List(); - + if (includeObjectFiles) files.AddRange(Directory.GetFiles(objectOutputFolder, "*.obj", SearchOption.TopDirectoryOnly)); - + if (includePdbFiles) files.AddRange(Directory.GetFiles(objectOutputFolder, "*.pdb", SearchOption.TopDirectoryOnly)); - + if (includeStaticLibraries) files.AddRange(Directory.GetFiles(objectOutputFolder, "*.lib", SearchOption.TopDirectoryOnly)); - + if (includeDynamicLibraries) { files.AddRange(Directory.GetFiles(objectOutputFolder, "*.dll", SearchOption.TopDirectoryOnly)); files.AddRange(Directory.GetFiles(objectOutputFolder, "*.so", SearchOption.TopDirectoryOnly)); } - + if (includeExecutables) { files.AddRange(Directory.GetFiles(objectOutputFolder, "*.exe", SearchOption.TopDirectoryOnly)); files.AddRange(Directory.GetFiles(objectOutputFolder, "", SearchOption.TopDirectoryOnly) .Where(f => !Path.HasExtension(f) && File.Exists(f))); } - + return files; } public static void ClearObjectAndPdbFiles(ModuleBase module, bool shouldLog = true) { var files = FindBuildArtifacts(module, includeObjectFiles: true, includePdbFiles: true); - + foreach (var file in files) { if (shouldLog) diff --git a/ebuild/Compilers/MSVCClCompiler.cs b/ebuild/Compilers/MSVCClCompiler.cs index b9acefe..1fb8b42 100644 --- a/ebuild/Compilers/MSVCClCompiler.cs +++ b/ebuild/Compilers/MSVCClCompiler.cs @@ -329,26 +329,47 @@ public override async Task Compile(CompilerSettings settings, Cancellation WorkingDirectory = Path.GetDirectoryName(settings.SourceFile) ?? Environment.CurrentDirectory }; var process = new Process { StartInfo = startInfo }; + List> recievedData = new List>(); process.ErrorDataReceived += (sender, e) => { if (!string.IsNullOrEmpty(e.Data)) { - Console.Error.WriteLine(e.Data); + recievedData.Add(new KeyValuePair("error", e.Data)); + // Console.Error.WriteLine(e.Data); } }; process.OutputDataReceived += (sender, e) => { if (!string.IsNullOrEmpty(e.Data)) { - Console.Out.WriteLine(e.Data); + recievedData.Add(new KeyValuePair("output", e.Data)); + // Console.Out.WriteLine(e.Data); } }; process.Start(); process.BeginErrorReadLine(); process.BeginOutputReadLine(); await process.WaitForExitAsync(cancellationToken); + // write in the order received + lock (writeLocker) + { + foreach (var data in recievedData) + { + if (data.Key is "error") + { + Console.Error.WriteLine(data.Value); + } + else + { + Console.Out.WriteLine(data.Value); + } + } + } + try { File.Delete(tempFile); } catch { /* ignore errors from deleting temp file */ } return process.ExitCode == 0; } + + public static object writeLocker = new(); } } diff --git a/ebuild/EBuild.cs b/ebuild/EBuild.cs index 6dd9f65..7c6f42b 100644 --- a/ebuild/EBuild.cs +++ b/ebuild/EBuild.cs @@ -3,6 +3,7 @@ using CliFx; using ebuild.api; using ebuild.api.Toolchain; +using ebuild.cli; using ebuild.Modules; using ebuild.Platforms; using Microsoft.Extensions.Logging; @@ -164,11 +165,12 @@ public static void InitializeEBuild() public static async Task Main(string[] args) { InitializeEBuild(); - - return await new CliApplicationBuilder() - .AddCommandsFromThisAssembly() - .Build() - .RunAsync(args); + CliParser parser = new(); + parser.RegisterCommandsFromAssembly(Assembly.GetExecutingAssembly()); + parser.Parse(args); + parser.ApplyParsedToCommands(); + CancellationToken token = default; + return await parser.ExecuteCurrentCommandAsync(token); } } } \ No newline at end of file diff --git a/ebuild/Modules/BuildGraph/BuildWorker.cs b/ebuild/Modules/BuildGraph/BuildWorker.cs index fc1a36a..ac02ed6 100644 --- a/ebuild/Modules/BuildGraph/BuildWorker.cs +++ b/ebuild/Modules/BuildGraph/BuildWorker.cs @@ -12,6 +12,7 @@ public class BuildWorker(Graph graph) : IWorker public Dictionary GlobalMetadata { get; init; } = []; public int MaxWorkerCount { get; set; } = 1; public ILogger Logger { get; init; } = EBuild.LoggerFactory.CreateLogger(); + public bool Clean = false; public async Task WorkOnNodesAsync(List nodes, CancellationToken cancellationToken) { @@ -78,8 +79,20 @@ await Parallel.ForEachAsync( } } } + // 2.1 copy shared libraries non-parallel + foreach (var node in nodes.Where(n => n is CopySharedLibraryToRootModuleBinNode)) + { + try + { + await node.ExecuteAsync(this, cancellationToken); + } + catch (Exception ex) + { + throw new CliFxException($"Copying shared library failed: {ex.Message}", 1, false, ex); + } + } // 3rd run additional dependency nodes non-parallel - foreach( var node in nodes.Where(n => n is AdditionalDependencyNode).Cast()) + foreach (var node in nodes.Where(n => n is AdditionalDependencyNode).Cast()) { try { @@ -90,7 +103,7 @@ await Parallel.ForEachAsync( throw new CliFxException($"Processing additional dependency failed: {ex.Message}", 1, false, ex); } } - + // 4th run post-build steps non-parallel foreach (var step in nodes.Where(n => n is BuildStepNode bsn && bsn.stepType == BuildStepNode.StepType.PostBuild).Cast()) { diff --git a/ebuild/Modules/BuildGraph/CopySharedLibraryNode.cs b/ebuild/Modules/BuildGraph/CopySharedLibraryNode.cs new file mode 100644 index 0000000..3cbc766 --- /dev/null +++ b/ebuild/Modules/BuildGraph/CopySharedLibraryNode.cs @@ -0,0 +1,37 @@ +using ebuild.api; + +namespace ebuild.Modules.BuildGraph; + + + +class CopySharedLibraryToRootModuleBinNode(string name, string sourcePath) : Node(name) +{ + public string SourcePath { get; init; } = sourcePath; + public override async Task ExecuteAsync(IWorker worker, CancellationToken cancellationToken) + { + ModuleBase root = worker.WorkingGraph.Module; + string outDir = root.GetBinaryOutputDirectory(); + try + { + if (!Directory.Exists(outDir)) + { + Directory.CreateDirectory(outDir); + } + File.Copy(SourcePath, Path.Combine(outDir, Path.GetFileName(SourcePath)), true); + var platform = worker.WorkingGraph.Module.Context.Platform; + if (platform.SupportsDebugFiles) + { + var debugFilePath = Path.ChangeExtension(SourcePath, worker.WorkingGraph.Module.Context.Platform.ExtensionForCompiledSourceFile_DebugFile); + if (File.Exists(debugFilePath)) + { + File.Copy(debugFilePath, Path.Combine(outDir, Path.GetFileName(debugFilePath)), true); + } + } + await Task.CompletedTask; + } + catch (Exception ex) + { + throw new Exception($"Failed to copy shared library from \"{SourcePath}\" to \"{Path.Combine(outDir, Path.GetFileName(SourcePath))}\": {ex.Message}", ex); + } + } +} \ No newline at end of file diff --git a/ebuild/Modules/BuildGraph/ModuleDeclarationNode.cs b/ebuild/Modules/BuildGraph/ModuleDeclarationNode.cs index a1c7583..8ccfeec 100644 --- a/ebuild/Modules/BuildGraph/ModuleDeclarationNode.cs +++ b/ebuild/Modules/BuildGraph/ModuleDeclarationNode.cs @@ -144,6 +144,8 @@ public ModuleDeclarationNode(ModuleBase module) : base("ModuleDeclaration") { linkInputs.Add(dependency.Module.GetBinaryOutputPath()); } + var copyNode = new CopySharedLibraryToRootModuleBinNode($"CopySharedLibrary_{dependency.Module.Name}", dependency.Module.GetBinaryOutputPath()); + AddChild(copyNode, AccessLimit.Private); break; case ModuleType.Executable: case ModuleType.ExecutableWin32: diff --git a/ebuild/ebuild.csproj b/ebuild/ebuild.csproj index 628b212..08644c8 100644 --- a/ebuild/ebuild.csproj +++ b/ebuild/ebuild.csproj @@ -15,5 +15,6 @@ + diff --git a/examples/additional-dependencies/main.c b/examples/additional-dependencies/main.c index 8bb7619..47c2de4 100644 --- a/examples/additional-dependencies/main.c +++ b/examples/additional-dependencies/main.c @@ -36,6 +36,4 @@ int main(int argc, char** argv) snprintf(example_buffer, sizeof(example_buffer), "%s/example.txt", path_buffer); fprintf(stdout, "Open file at path: %s\n", example_buffer); print_file_content(example_buffer); - - } \ No newline at end of file