Skip to content

Commit a13dddc

Browse files
authored
Merge Feature/ebuild.cli. (#26)
* Implement CLI framework with command and option parsing; add necessary attributes and processors * Refactor CLI argument processing: enhance command handling and remove unused OptionProcessor * Some shared library fixes * Refactor command structure and introduce command attributes - Enhanced the Command class to support nested subcommands and improved name resolution using CommandAttribute. - Introduced CommandAttribute to define command metadata such as name, aliases, and description. - Added HelpCommand for displaying help information. - Created IStringConverter interface for custom string conversion logic. - Updated OptionAttribute to include a Global flag for options applicable at any position. - Modified BaseCommand and ModuleCreatingCommand to utilize the new command structure. - Refactored various command implementations (BuildCommand, CheckCommand, GenerateCommand, PropertyCommand) to align with the new command model. - Updated project references and dependencies in the project files.
1 parent 089c5c5 commit a13dddc

30 files changed

Lines changed: 1670 additions & 223 deletions
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
using NUnit.Framework;
2+
using System.Collections.Generic;
3+
using ebuild.cli;
4+
5+
namespace ebuild.Tests.Unit
6+
{
7+
[TestFixture]
8+
public class CliParserConverterTests
9+
{
10+
// Simple converter used by tests
11+
public class UpperCaseConverter : IStringConverter
12+
{
13+
public object Convert(string value) => value.ToUpperInvariant();
14+
}
15+
16+
class TestConvRoot : Command
17+
{
18+
[Option("opt", ConverterType = typeof(UpperCaseConverter))]
19+
public string? Opt;
20+
}
21+
22+
[Test]
23+
public void Uses_IStringConverter_for_field()
24+
{
25+
var parser = new CliParser(typeof(TestConvRoot));
26+
parser.Parse(new[] { "cmd", "--opt=hello" });
27+
parser.ApplyParsedToCommands();
28+
var root = (TestConvRoot)parser.currentCommandChain.First!.Value;
29+
Assert.That(root.Opt, Is.EqualTo("HELLO"));
30+
}
31+
32+
class TestDictRoot : Command
33+
{
34+
[Option("map")]
35+
public Dictionary<string, int>? Map;
36+
}
37+
38+
[Test]
39+
public void Dictionary_values_are_converted_to_int()
40+
{
41+
var parser = new CliParser(typeof(TestDictRoot));
42+
parser.Parse(new[] { "cmd", "--map=foo=42", "--map=bar=7" });
43+
parser.ApplyParsedToCommands();
44+
var root = (TestDictRoot)parser.currentCommandChain.First!.Value;
45+
Assert.That(root.Map.ContainsKey("foo") && root.Map["foo"] == 42);
46+
Assert.That(root.Map.ContainsKey("bar") && root.Map["bar"] == 7);
47+
}
48+
}
49+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
using NUnit.Framework;
2+
using ebuild.cli;
3+
4+
namespace ebuild.Tests.Unit
5+
{
6+
[TestFixture]
7+
public class CliParserSubcommandTests
8+
{
9+
[Command("sub", Aliases = new[] { "s" })]
10+
class SubCommand : Command
11+
{
12+
[Option("flag")]
13+
public bool Flag;
14+
}
15+
16+
class RootWithSub : Command
17+
{
18+
// root has no options; subcommand added at runtime
19+
}
20+
21+
class RootWithNested : Command
22+
{
23+
[Command("nested")]
24+
public class Nested : Command
25+
{
26+
[Option("flag")]
27+
public bool Flag;
28+
}
29+
}
30+
31+
class RootWithNestedOptOut : Command
32+
{
33+
[Command("noauto", AutoRegister = false)]
34+
public class NoAuto : Command
35+
{
36+
}
37+
}
38+
39+
[Test]
40+
public void Subcommand_switches_and_parses_option()
41+
{
42+
var parser = new CliParser(typeof(RootWithSub));
43+
// add a subcommand instance to the root created inside the parser
44+
var root = (RootWithSub)parser.currentCommandChain.First!.Value;
45+
var sub = new SubCommand();
46+
root.AddSubCommand(sub);
47+
48+
parser.Parse(new[] { "cmd", "sub", "--flag" });
49+
parser.ApplyParsedToCommands();
50+
51+
// ensure current command chain ended on the subcommand instance and its option was set
52+
var last = parser.currentCommandChain.Last!.Value as SubCommand;
53+
Assert.That(last, Is.Not.Null);
54+
Assert.That(last!.Flag, Is.True);
55+
}
56+
57+
[Test]
58+
public void Subcommand_alias_is_recognized()
59+
{
60+
var parser = new CliParser(typeof(RootWithSub));
61+
var root = (RootWithSub)parser.currentCommandChain.First!.Value;
62+
var sub = new SubCommand();
63+
root.AddSubCommand(sub);
64+
65+
parser.Parse(new[] { "cmd", "s", "--flag" });
66+
parser.ApplyParsedToCommands();
67+
68+
var last = parser.currentCommandChain.Last!.Value as SubCommand;
69+
Assert.That(last, Is.Not.Null);
70+
Assert.That(last!.Flag, Is.True);
71+
}
72+
73+
[Test]
74+
public void Nested_subcommand_is_auto_registered()
75+
{
76+
var parser = new CliParser(typeof(RootWithNested));
77+
parser.Parse(new[] { "cmd", "nested", "--flag" });
78+
parser.ApplyParsedToCommands();
79+
80+
var last = parser.currentCommandChain.Last!.Value as RootWithNested.Nested;
81+
Assert.That(last, Is.Not.Null);
82+
Assert.That(last!.Flag, Is.True);
83+
}
84+
85+
[Test]
86+
public void Nested_subcommand_opt_out_prevents_registration()
87+
{
88+
var parser = new CliParser(typeof(RootWithNestedOptOut));
89+
parser.Parse(new[] { "cmd", "noauto" });
90+
parser.ApplyParsedToCommands();
91+
92+
// should remain on root because nested class opted out
93+
Assert.That(parser.currentCommandChain.Last!.Value.GetType(), Is.EqualTo(typeof(RootWithNestedOptOut)));
94+
}
95+
}
96+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
using NUnit.Framework;
2+
using System;
3+
using ebuild.cli;
4+
5+
namespace ebuild.Tests.Unit
6+
{
7+
[TestFixture]
8+
public class CliParserTests
9+
{
10+
class TestRoot : Command
11+
{
12+
[Option("verbose", ShortName = "v")]
13+
public bool Verbose;
14+
15+
[Option("define", ShortName = "D")]
16+
public string? Define;
17+
[Argument(0, Name = "input")]
18+
public string? Input;
19+
}
20+
21+
[Test]
22+
public void Parses_long_option_with_equals()
23+
{
24+
var parser = new CliParser(typeof(TestRoot));
25+
parser.Parse(new[] { "cmd", "--define=Z=1" });
26+
parser.ApplyParsedToCommands();
27+
var root = (TestRoot)parser.currentCommandChain.First!.Value;
28+
Assert.That(root.Define, Is.EqualTo("Z=1"));
29+
}
30+
31+
[Test]
32+
public void Negative_number_is_argument_when_no_option()
33+
{
34+
var parser = new CliParser(typeof(TestRoot));
35+
parser.Parse(new[] { "cmd", "-42", "foo" });
36+
parser.ApplyParsedToCommands();
37+
Assert.That(parser.ParsedOptions.Count, Is.EqualTo(0));
38+
Assert.That(parser.ParsedArguments.Count, Is.EqualTo(2));
39+
Assert.That(parser.ParsedArguments[0].Value, Is.EqualTo("-42"));
40+
}
41+
42+
[Test]
43+
public void Positional_argument_maps_to_field()
44+
{
45+
var parser = new CliParser(typeof(TestRoot));
46+
parser.Parse(new[] { "cmd", "input.txt" });
47+
parser.ApplyParsedToCommands();
48+
var root = (TestRoot)parser.currentCommandChain.First!.Value;
49+
Assert.That(root.Input, Is.EqualTo("input.txt"));
50+
}
51+
52+
[Test]
53+
public void Duplicate_option_policy_error_throws()
54+
{
55+
var parser = new CliParser(typeof(TestRoot), DuplicateOptionPolicy.Error);
56+
parser.Parse(new[] { "cmd", "--define=one", "--define=two" });
57+
Assert.Throws<InvalidOperationException>(() => parser.ApplyParsedToCommands());
58+
}
59+
60+
[Test]
61+
public void Duplicate_option_policy_warn_last_wins()
62+
{
63+
var parser = new CliParser(typeof(TestRoot), DuplicateOptionPolicy.Warn);
64+
parser.Parse(new[] { "cmd", "--define=one", "--define=two" });
65+
parser.ApplyParsedToCommands();
66+
var root = (TestRoot)parser.currentCommandChain.First!.Value;
67+
Assert.That(root.Define, Is.EqualTo("two"));
68+
}
69+
70+
[Test]
71+
public void Duplicate_option_policy_ignore_last_wins()
72+
{
73+
var parser = new CliParser(typeof(TestRoot), DuplicateOptionPolicy.Ignore);
74+
parser.Parse(new[] { "cmd", "--define=one", "--define=two" });
75+
parser.ApplyParsedToCommands();
76+
var root = (TestRoot)parser.currentCommandChain.First!.Value;
77+
Assert.That(root.Define, Is.EqualTo("two"));
78+
}
79+
80+
[Test]
81+
public void Disallow_combined_short_flags()
82+
{
83+
var parser = new CliParser(typeof(TestRoot));
84+
parser.Parse(new[] { "cmd", "-abc" });
85+
parser.ApplyParsedToCommands();
86+
// Should treat as a single option name 'abc'
87+
Assert.That(parser.ParsedOptions.Count, Is.EqualTo(1));
88+
Assert.That(parser.ParsedOptions[0].Name, Is.EqualTo("abc"));
89+
}
90+
91+
[Test]
92+
public void Short_option_attached_value_with_equals_parses()
93+
{
94+
var parser = new CliParser(typeof(TestRoot));
95+
parser.Parse(new[] { "cmd", "-DZLIB=1.2.11" });
96+
parser.ApplyParsedToCommands();
97+
var root = (TestRoot)parser.currentCommandChain.First!.Value;
98+
Assert.That(root.Define, Is.EqualTo("ZLIB=1.2.11"));
99+
}
100+
101+
[Test]
102+
public void Short_option_attached_quoted_value_parses()
103+
{
104+
var parser = new CliParser(typeof(TestRoot));
105+
parser.Parse(new[] { "cmd", "-D\"USE_ZLIB\"" });
106+
parser.ApplyParsedToCommands();
107+
var root = (TestRoot)parser.currentCommandChain.First!.Value;
108+
Assert.That(root.Define, Is.EqualTo("USE_ZLIB"));
109+
}
110+
111+
[Test]
112+
public void Short_option_attached_quoted_value_with_spaces_parses()
113+
{
114+
var parser = new CliParser(typeof(TestRoot));
115+
parser.Parse(new[] { "cmd", "-D\"Use ZLIB Data\"" });
116+
parser.ApplyParsedToCommands();
117+
var root = (TestRoot)parser.currentCommandChain.First!.Value;
118+
Assert.That(root.Define, Is.EqualTo("Use ZLIB Data"));
119+
}
120+
}
121+
}

ebuild.Tests/ebuild.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
<ItemGroup>
1414
<ProjectReference Include="../ebuild/ebuild.csproj" />
1515
<ProjectReference Include="../ebuild.api/ebuild.api.csproj" />
16+
<ProjectReference Include="../ebuild.cli/ebuild.cli.csproj" />
1617
</ItemGroup>
1718
<PropertyGroup>
1819
<Nullable>enable</Nullable>

ebuild.api/Definition.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
namespace ebuild.api
44
{
5-
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]
65
/// <summary>
76
/// Represents a single preprocessor/definition entry typically specified as
87
/// a string in the form <c>NAME=VALUE</c> or simply <c>NAME</c>.
@@ -15,6 +14,7 @@ namespace ebuild.api
1514
/// </summary>
1615
/// <param name="inValue">The raw definition string passed to the constructor.
1716
/// Expected formats: <c>NAME=VALUE</c> or <c>NAME</c>.</param>
17+
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]
1818
public class Definition(string inValue)
1919
{
2020
/// <summary>

ebuild.api/ModuleBase.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -412,9 +412,10 @@ public string GetOutputTransformerName()
412412
/// <returns>Absolute path to the module's binary output directory, always ending with a directory separator.</returns>
413413
public string GetBinaryOutputDirectory()
414414
{
415+
var basePath = Path.Combine(Context.ModuleDirectory!.FullName, OutputDirectory, Context.Configuration, GetOutputTransformerName());
415416
if (UseVariants)
416-
return Path.TrimEndingDirectorySeparator(Path.Combine(Context.ModuleDirectory!.FullName, OutputDirectory, GetOutputTransformerName(), GetVariantId().ToString()) + Path.DirectorySeparatorChar);
417-
return Path.TrimEndingDirectorySeparator(Path.Combine(Context.ModuleDirectory!.FullName, OutputDirectory, GetOutputTransformerName()) + Path.DirectorySeparatorChar);
417+
return Path.TrimEndingDirectorySeparator(Path.Combine(basePath, GetVariantId().ToString()));
418+
return Path.TrimEndingDirectorySeparator(basePath);
418419
}
419420

420421

ebuild.api/ModuleContext.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@
44

55
namespace ebuild.api
66
{
7-
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]
87
/// <summary>
98
/// Runtime context provided to modules when they are instantiated.
109
///
1110
/// This class carries information about the calling environment such as the
1211
/// module reference, target platform, toolchain, architecture, and option map.
1312
/// Module implementations use this context during construction and initialization.
1413
/// </summary>
14+
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]
1515
public class ModuleContext
1616
{
1717
/// <summary>
@@ -27,6 +27,7 @@ public ModuleContext(ModuleContext m)
2727
Toolchain = m.Toolchain;
2828
TargetArchitecture = m.TargetArchitecture;
2929
Options = m.Options;
30+
Configuration = m.Configuration;
3031
AdditionalDependencyPaths = m.AdditionalDependencyPaths;
3132
}
3233

@@ -107,7 +108,6 @@ public ModuleContext(ModuleReference reference, PlatformBase platform, IToolchai
107108
/// </summary>
108109
public string RequestedOutput => SelfReference.GetOutput();
109110

110-
111111
/// <summary>
112112
/// Simple message wrapper used to surface informational/warning/error messages produced
113113
/// during module instantiation.

ebuild.cli/ArgumentAttribute.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using System;
2+
3+
namespace ebuild.cli
4+
{
5+
[AttributeUsage(AttributeTargets.Field, AllowMultiple = false)]
6+
public class ArgumentAttribute : Attribute
7+
{
8+
public int Order { get; }
9+
public string? Name { get; set; }
10+
public string? Description { get; set; }
11+
public bool AllowMultiple { get; set; } = false;
12+
public bool IsRequired { get; set; } = false;
13+
14+
public ArgumentAttribute(int order)
15+
{
16+
Order = order;
17+
}
18+
}
19+
}

0 commit comments

Comments
 (0)