commit 9fdd499d7a213625ad51e6a1f2d144af4dd8500a Author: That-One-Nerd Date: Tue Feb 4 18:43:36 2025 -0500 Initial commit with code already here. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..976be09 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# Visual Studio things. +.vs/ + +# Build Stuff +*/bin +*/obj diff --git a/ArgumentBase.sln b/ArgumentBase.sln new file mode 100644 index 0000000..1f30564 --- /dev/null +++ b/ArgumentBase.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.8.34309.116 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArgumentBase", "ArgumentBase\ArgumentBase.csproj", "{E6AFC35D-D69A-4956-9ED4-AF0276192EE8}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {E6AFC35D-D69A-4956-9ED4-AF0276192EE8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E6AFC35D-D69A-4956-9ED4-AF0276192EE8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E6AFC35D-D69A-4956-9ED4-AF0276192EE8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E6AFC35D-D69A-4956-9ED4-AF0276192EE8}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {DF77961F-683C-444B-9866-FB4C081B3E24} + EndGlobalSection +EndGlobal diff --git a/ArgumentBase/ArgFlagInfo.cs b/ArgumentBase/ArgFlagInfo.cs new file mode 100644 index 0000000..1666ef9 --- /dev/null +++ b/ArgumentBase/ArgFlagInfo.cs @@ -0,0 +1,12 @@ +using System.Reflection; + +namespace ArgumentBase; + +public class ArgFlagInfo +{ + public required string Name { get; init; } + public required string Description { get; init; } + public required PropertyInfo Property { get; init; } + + internal ArgFlagInfo() { } +} diff --git a/ArgumentBase/ArgParameterInfo.cs b/ArgumentBase/ArgParameterInfo.cs new file mode 100644 index 0000000..7ae9512 --- /dev/null +++ b/ArgumentBase/ArgParameterInfo.cs @@ -0,0 +1,13 @@ +using System.Reflection; + +namespace ArgumentBase; + +public class ArgParameterInfo +{ + public required int Order { get; init; } + public required string Name { get; init; } + public required string Description { get; init; } + public required PropertyInfo Property { get; init; } + + internal ArgParameterInfo() { } +} diff --git a/ArgumentBase/ArgVariableInfo.cs b/ArgumentBase/ArgVariableInfo.cs new file mode 100644 index 0000000..6b63bee --- /dev/null +++ b/ArgumentBase/ArgVariableInfo.cs @@ -0,0 +1,12 @@ +using System.Reflection; + +namespace ArgumentBase; + +public class ArgVariableInfo +{ + public required string Name { get; init; } + public required string Description { get; init; } + public required PropertyInfo Property { get; init; } + + internal ArgVariableInfo() { } +} diff --git a/ArgumentBase/ArgumentBase.cs b/ArgumentBase/ArgumentBase.cs new file mode 100644 index 0000000..10b848e --- /dev/null +++ b/ArgumentBase/ArgumentBase.cs @@ -0,0 +1,291 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reflection; +using System.Text; + +namespace ArgumentBase; + +#pragma warning disable IL2070, IL2090 // Shut up. + +public abstract class ArgumentBase where TSelf : ArgumentBase, new() +{ + public static ReadOnlyCollection Parameters { get; private set; } + public static ReadOnlyCollection Variables { get; private set; } + public static ReadOnlyCollection Flags { get; private set; } + + public static ReadOnlyDictionary ParameterDescriptions { get; private set; } + public static ReadOnlyDictionary VariableDescriptions { get; private set; } + public static ReadOnlyDictionary FlagDescriptions { get; private set; } + public static ReadOnlyDictionary VariableTable { get; private set; } + public static ReadOnlyDictionary FlagTable { get; private set; } + + static ArgumentBase() + { + IEnumerable allProps = typeof(TSelf).GetProperties().Where(x => x.SetMethod is not null); + + List parameters = []; + List variables = []; + List flags = []; + + Dictionary paramDesc = [], varDesc = [], flagDesc = []; + Dictionary varTable = [], flagTable = []; + foreach (PropertyInfo prop in allProps) + { + IsParameterAttribute? paramAtt = prop.GetCustomAttribute(); + IsVariableAttribute? varAtt = prop.GetCustomAttribute(); + IsFlagAttribute? flagAtt = prop.GetCustomAttribute(); + + if (paramAtt is not null) + { + ArgParameterInfo info = new() + { + Order = paramAtt.order, + Name = paramAtt.name ?? prop.Name, + Description = paramAtt.description, + Property = prop + }; + parameters.Add(info); + + string trueName = info.Name.Trim(); + paramDesc.Add(trueName, info.Description); + } + if (varAtt is not null) + { + ArgVariableInfo info = new() + { + Name = varAtt.name ?? prop.Name, + Description = varAtt.description, + Property = prop + }; + variables.Add(info); + + string trueName = $"-{info.Name.Trim()}"; + varDesc.Add(trueName, info.Description); + varTable.Add(trueName, info.Property); + } + if (flagAtt is not null) + { + ArgFlagInfo info = new() + { + Name = flagAtt.name ?? prop.Name, + Description = flagAtt.description, + Property = prop + }; + flags.Add(info); + + string trueName = $"--{info.Name.Trim()}"; + flagDesc.Add(trueName, info.Description); + flagTable.Add(trueName, info.Property); + } + } + + parameters.Sort((a, b) => a.Order.CompareTo(b.Order)); + Parameters = parameters.AsReadOnly(); + Variables = variables.AsReadOnly(); + Flags = flags.AsReadOnly(); + + // I would sort these, but I would need yet another for loop to do that. + ParameterDescriptions = paramDesc.AsReadOnly(); + VariableDescriptions = varDesc.AsReadOnly(); + FlagDescriptions = flagDesc.AsReadOnly(); + VariableTable = varTable.AsReadOnly(); + FlagTable = flagTable.AsReadOnly(); + } + + public static TSelf Parse(string[] args) + { + TSelf result = new(); + if (args.Length == 0) return result; + + result.anyArguments = true; + int parameterIndex = 0; + List unknownParams = [], unknownVars = [], unknownFlags = []; + List badValParams = [], badValVars = []; + foreach (string arg in args) + { + if (arg.StartsWith("--")) // Flag + { + string name = arg[2..]; + ArgFlagInfo? flag = Flags.SingleOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + + if (flag is null) + { + // Unknown flag. + unknownFlags.Add(name); + continue; + } + + // Flip flag. + bool original = (bool)flag.Property.GetValue(result)!; + flag.Property.SetValue(result, !original); + result.anyFlags = true; + } + else if (arg.StartsWith('-')) // Variable + { + int splitter = arg.IndexOf(':'); + string name = arg[1..splitter], valueStr = arg[(splitter + 1)..]; + ArgVariableInfo? var = Variables.SingleOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + + if (var is null) + { + // Unknown variable. + unknownVars.Add(name); + continue; + } + + object? value = parseObject(valueStr, var.Property.PropertyType); + if (value is null) + { + // Issue parsing the object. + badValVars.Add(name); + continue; + } + var.Property.SetValue(result, value); + result.anyVars = true; + } + else // Parameter + { + if (parameterIndex >= Parameters.Count) + { + // Too many parameters. + unknownParams.Add(arg); + continue; + } + ArgParameterInfo param = Parameters[parameterIndex]; + parameterIndex++; + + object? value = parseObject(arg, param.Property.PropertyType); + if (value is null) + { + // Issue parsing the object. + badValParams.Add(arg); + continue; + } + param.Property.SetValue(result, value); + result.anyParams = true; + } + } + + int unknownArgs = unknownParams.Count + unknownVars.Count + unknownFlags.Count; + if (unknownArgs > 0) + { + // Unknown arguments. + StringBuilder warnMsg = new(); + warnMsg.Append($" \x1b[3;33m{unknownArgs} {(unknownArgs == 1 ? "argument was" : "arguments were")} not recognized:\n "); + int lineLen = 2, maxLineLen = (int)(0.65 * Console.WindowWidth - 1); + + foreach (string badParam in unknownParams) + { + if (lineLen + badParam.Length + 3 > maxLineLen) + { + warnMsg.Append("\n "); + lineLen = 2; + } + warnMsg.Append($"\"\x1b[37m{badParam}\x1b[33m\" "); + lineLen += badParam.Length + 3; + } + foreach (string badVar in unknownVars) + { + if (lineLen + badVar.Length + 4 > maxLineLen) + { + warnMsg.Append("\n "); + lineLen = 2; + } + warnMsg.Append($"\"\x1b[36m-{badVar}\x1b[33m\" "); + lineLen += badVar.Length + 4; + } + foreach (string badFlag in unknownFlags) + { + if (lineLen + badFlag.Length + 5 > maxLineLen) + { + warnMsg.Append("\n "); + lineLen = 2; + } + warnMsg.Append($"\"\x1b[90m--{badFlag}\x1b[33m\" "); + lineLen += badFlag.Length + 5; + } + warnMsg.AppendLine("\x1b[0m"); + Console.WriteLine(warnMsg); + } + int badValArgs = badValParams.Count + badValVars.Count; + if (badValArgs > 0) + { + // Issue parsing arguments. + StringBuilder warnMsg = new(); + warnMsg.Append($" \x1b[3;33m{badValArgs} {(badValArgs == 1 ? "value" : "values")} couldn't be parsed:\n "); + int lineLen = 2, maxLineLen = (int)(0.65 * Console.WindowWidth - 1); + + foreach (string badParam in badValParams) + { + if (lineLen + badParam.Length + 3 > maxLineLen) + { + warnMsg.Append("\n "); + lineLen = 2; + } + warnMsg.Append($"\"\x1b[37m{badParam}\x1b[33m\" "); + lineLen += badParam.Length + 3; + } + foreach (string badVar in badValVars) + { + if (lineLen + badVar.Length + 4 > maxLineLen) + { + warnMsg.Append("\n "); + lineLen = 2; + } + warnMsg.Append($"\"\x1b[36m-{badVar}\x1b[33m\" "); + lineLen += badVar.Length + 4; + } + warnMsg.AppendLine("\x1b[0m"); + Console.WriteLine(warnMsg); + } + + return result; + + static object? parseObject(string value, Type desired) + { + if (desired == typeof(string)) return value; + else if (desired.IsEnum) return Enum.TryParse(desired, value, true, out object? enumResult) ? enumResult : null; + else if (desired.GetInterface("IParsable`1") is null) throw new("Type must derive from IParsable. Sorry."); + else + { + // A bit large. Couldn't condense into a single lambda because I wanted to cache the parameters. + // I have to do all this weird stuff as a whole to be able to parse any object that derives + // from IParsable, making extra stuff easier to implement. + MethodInfo tryParseMethod = (from x in desired.GetMethods() + let parameters = x.GetParameters() + let goodName = x.Name == "TryParse" + let goodAttributes = x.IsPublic && x.IsStatic + let goodParams = parameters.Length == 2 + && parameters[0].ParameterType == typeof(string) + && parameters[1].ParameterType.GetElementType() == desired + where goodName && goodAttributes && goodParams + select x).Single(); // Must exist according to IParsable. + + // Output parameters are placed in the array. + object?[] methodParams = [value, null]; + bool success = (bool)tryParseMethod.Invoke(null, methodParams)!; + return success ? methodParams[1] : null; + } + } + } + public static void PrintParameters(string? keyFormat = null) + { + if (Parameters.Count == 0) return; + else PrintHelper.PrintKeyValues("Parameters", 2, ParameterDescriptions.ToDictionary(), keyFormat: keyFormat ?? "\x1b[37m"); + } + public static void PrintVariables() + { + if (Variables.Count == 0) return; + else PrintHelper.PrintKeyValues("Variables", 2, VariableDescriptions.ToDictionary(), keyFormat: "\x1b[36m"); + } + public static void PrintFlags() + { + if (Flags.Count == 0) return; + else PrintHelper.PrintKeyValues("Flags", 2, FlagDescriptions.ToDictionary()); + } + + public bool anyArguments; + public bool anyParams, anyVars, anyFlags; +} diff --git a/ArgumentBase/ArgumentBase.csproj b/ArgumentBase/ArgumentBase.csproj new file mode 100644 index 0000000..d9110de --- /dev/null +++ b/ArgumentBase/ArgumentBase.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + disable + enable + True + ArgumentBase + ArgumentBase + 1.0.0 + That_One_Nerd + A small library that handles parsing console arguments. + MIT + README.md + + + + True + + + + True + + + + + True + \ + + + + diff --git a/ArgumentBase/IsFlagAttribute.cs b/ArgumentBase/IsFlagAttribute.cs new file mode 100644 index 0000000..367f6db --- /dev/null +++ b/ArgumentBase/IsFlagAttribute.cs @@ -0,0 +1,21 @@ +using System; + +namespace ArgumentBase; + +[AttributeUsage(AttributeTargets.Property)] +public class IsFlagAttribute : Attribute +{ + public readonly string description; + public readonly string? name; + + public IsFlagAttribute(string description) + { + this.description = description; + name = null; + } + public IsFlagAttribute(string name, string description) + { + this.description = description; + this.name = name; + } +} diff --git a/ArgumentBase/IsParameterAttribute.cs b/ArgumentBase/IsParameterAttribute.cs new file mode 100644 index 0000000..2164c14 --- /dev/null +++ b/ArgumentBase/IsParameterAttribute.cs @@ -0,0 +1,24 @@ +using System; + +namespace ArgumentBase; + +[AttributeUsage(AttributeTargets.Property)] +public class IsParameterAttribute : Attribute +{ + public readonly int order; + public readonly string description; + public readonly string? name; + + public IsParameterAttribute(int order, string description) + { + this.order = order; + this.description = description; + name = null; + } + public IsParameterAttribute(int order, string name, string description) + { + this.order = order; + this.description = description; + this.name = name; + } +} diff --git a/ArgumentBase/IsVariableAttribute.cs b/ArgumentBase/IsVariableAttribute.cs new file mode 100644 index 0000000..ee3aeb0 --- /dev/null +++ b/ArgumentBase/IsVariableAttribute.cs @@ -0,0 +1,21 @@ +using System; + +namespace ArgumentBase; + +[AttributeUsage(AttributeTargets.Property)] +public class IsVariableAttribute : Attribute +{ + public readonly string description; + public readonly string? name; + + public IsVariableAttribute(string description) + { + this.description = description; + name = null; + } + public IsVariableAttribute(string name, string description) + { + this.description = description; + this.name = name; + } +} diff --git a/ArgumentBase/PrintHelper.cs b/ArgumentBase/PrintHelper.cs new file mode 100644 index 0000000..d33ee22 --- /dev/null +++ b/ArgumentBase/PrintHelper.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Reflection; +using System.Text; + +namespace ArgumentBase; + +internal static class PrintHelper +{ + public static void PrintKeyValues(string title, int indent, Dictionary values, + string? keyFormat = null, string? separatorFormat = null, string? valueFormat = null) + { + StringBuilder result = new(); + result.Append($"{new string(' ', indent)}\x1b[1;97m{title}:\x1b[22m\n"); + + int maxLength = 0; + StringBuilder[] lines = new StringBuilder[values.Count]; + IEnumerator> iterator = values.GetEnumerator(); + for (int i = 0; i < values.Count; i++) + { + iterator.MoveNext(); + KeyValuePair kv = iterator.Current; + lines[i] = new StringBuilder().Append($"{new string(' ', indent + 2)}{keyFormat ?? "\x1b[90m"}{kv.Key}"); + + int rawKeyLength = visibleStringLength(kv.Key); + if (rawKeyLength > maxLength) maxLength = kv.Key.Length; + } + + int desired = maxLength + 2; + iterator.Reset(); + for (int i = 0; i < values.Count; i++) + { + iterator.MoveNext(); + KeyValuePair kv = iterator.Current; + int rawKeyLength = visibleStringLength(kv.Key); + int remaining = desired - rawKeyLength; + + lines[i].Append($"{new string(' ', remaining)}{separatorFormat ?? "\x1b[91m- "}{valueFormat ?? "\x1b[37m"}{kv.Value}\x1b[0m"); + result.Append(lines[i]); + result.AppendLine(); + } + Console.WriteLine(result); + + static int visibleStringLength(string str) => str.ToCharArray().Where(x => !char.IsControl(x)).Count(); + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..0ffaf68 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# ArgumentBase + +This is a small library that will parse your command-line arguments for you in a pretty easy-to-use fashion. +This library only exists because I wrote this code for a different project and then found myself copy-pasting +it constantly. This is to ease my own suffering. + +This is largely purpose-built for me, but feel free to use it. That's why it's here. + +## How to use + +Import the package using your preferred method. Then, once it's installed, create a class like this: +```csharp +public class YourArguments : ArgumentBase +{ + // Positional arguments are called "parameters." No prefix is necessary. + [IsParameter(1, "An example description for the parameter.")] + public string ExampleParameter { get; set; } // It must be a property with a set method or it won't be identified. + + // Variables are formatted like this: "-var:3.14" + [IsVariable("var", "An example description for the variable.")] + public double ExampleVariable { get; set; } // Parameters and variables can be any object that derives from IParsable. + + // Flags are formatted like this: "--flag" + [IsFlag("flag", "An example description for the flag.")] + public bool ExampleFlag { get; set; } // Flags, however, can only be booleans. + // Whenever a flag is included as an argument, its value is flipped. +} +``` + +Then, in your Main method, paste the following: +```csharp +static void Main(string[] args) +{ + YourArguments args = YourArguments.Parse(args); +} +``` + +That's it. Not too bad, right?