feat: initial commit

This commit is contained in:
Dylan R. E. Moonfire 2021-09-10 12:33:42 -05:00
commit ad0525be04
36 changed files with 15711 additions and 0 deletions

122
.editorconfig Normal file
View file

@ -0,0 +1,122 @@
# EditorConfig is awesome: https://EditorConfig.org
root = true
[*]
charset=utf-8
end_of_line = lf
insert_final_newline=true
indent_style=space
indent_size=4
# Microsoft .NET properties
csharp_new_line_before_members_in_object_initializers=false
csharp_preferred_modifier_order=public, private, protected, internal, new, abstract, virtual, sealed, override, static, readonly, extern, unsafe, volatile, async:suggestion
csharp_space_after_cast=false
csharp_style_var_elsewhere=false:hint
csharp_style_var_for_built_in_types=false:hint
csharp_style_var_when_type_is_apparent=true:hint
csharp_preserve_single_line_statements=false
csharp_preserve_single_line_blocks=true
dotnet_style_predefined_type_for_locals_parameters_members=true:hint
dotnet_style_predefined_type_for_member_access=true:hint
dotnet_style_qualification_for_event=true:hint
dotnet_style_qualification_for_field=true:hint
dotnet_style_qualification_for_method=true:hint
dotnet_style_qualification_for_property=true:hint
dotnet_style_require_accessibility_modifiers=for_non_interface_members:hint
# ReSharper properties
resharper_alignment_tab_fill_style=optimal_fill
resharper_apply_on_completion=true
resharper_blank_lines_after_control_transfer_statements=1
resharper_blank_lines_around_single_line_auto_property=1
resharper_blank_lines_around_single_line_property=1
resharper_blank_lines_before_single_line_comment=1
resharper_blank_lines_between_using_groups=1
resharper_braces_for_for=required
resharper_braces_for_foreach=required
resharper_braces_for_ifelse=required
resharper_braces_for_while=required
resharper_can_use_global_alias=false
resharper_csharp_blank_lines_around_single_line_field=1
resharper_csharp_blank_lines_around_single_line_invocable=1
resharper_csharp_indent_style=tab
resharper_csharp_insert_final_newline=true
resharper_csharp_keep_blank_lines_in_code=1
resharper_csharp_keep_blank_lines_in_declarations=1
resharper_csharp_new_line_before_while=true
resharper_csharp_use_indent_from_vs=false
resharper_csharp_wrap_arguments_style=chop_if_long
resharper_csharp_wrap_extends_list_style=chop_if_long
resharper_csharp_wrap_parameters_style=chop_if_long
resharper_css_insert_final_newline=false
resharper_enforce_line_ending_style=true
resharper_html_insert_final_newline=false
resharper_indent_nested_fixed_stmt=true
resharper_js_indent_style=tab
resharper_js_insert_final_newline=true
resharper_js_keep_blank_lines_in_code=1
resharper_js_stick_comment=false
resharper_js_use_indent_from_vs=false
resharper_js_wrap_before_binary_opsign=true
resharper_js_wrap_chained_method_calls=chop_if_long
resharper_keep_blank_lines_between_declarations=1
resharper_min_blank_lines_after_imports=1
resharper_place_attribute_on_same_line=False
resharper_place_constructor_initializer_on_same_line=false
resharper_place_type_constraints_on_same_line=false
resharper_protobuf_insert_final_newline=false
resharper_qualified_using_at_nested_scope=true
resharper_resx_insert_final_newline=false
resharper_space_within_single_line_array_initializer_braces=true
resharper_use_indents_from_main_language_in_file=false
resharper_vb_insert_final_newline=false
resharper_wrap_after_declaration_lpar=true
resharper_wrap_after_invocation_lpar=true
resharper_wrap_before_extends_colon=true
resharper_wrap_before_first_type_parameter_constraint=true
resharper_wrap_before_type_parameter_langle=true
resharper_xmldoc_indent_child_elements=ZeroIndent
resharper_xmldoc_indent_text=ZeroIndent
resharper_xmldoc_insert_final_newline=false
resharper_xml_insert_final_newline=false
# ReSharper inspection severities
resharper_check_namespace_highlighting=none
resharper_convert_to_auto_property_highlighting=none
resharper_localizable_element_highlighting=none
resharper_redundant_comma_in_attribute_list_highlighting=none
resharper_redundant_comma_in_enum_declaration_highlighting=none
resharper_redundant_comma_in_initializer_highlighting=none
resharper_string_compare_to_is_culture_specific_highlighting=none
resharper_string_index_of_is_culture_specific_1_highlighting=none
resharper_use_null_propagation_highlighting=none
resharper_use_object_or_collection_initializer_highlighting=hint
resharper_use_string_interpolation_highlighting=hint
# Matches the exact files either package.json or .travis.yml
[{package.json,.travis.yml}]
indent_style=space
indent_size=2
tab_width=2
[*.{cs,js,json,jsx,proto,resjson,ts,tsx}]
indent_style=space
indent_size=space
tab_width=4
[*.{asax,ascx,aspx,cshtml,css,htm,html,master,razor,skin,vb,xaml,xamlx,xoml}]
indent_style=space
indent_size=4
tab_width=4
[*.{appxmanifest,build,config,csproj,dbml,discomap,dtd,jsproj,lsproj,njsproj,nuspec,proj,props,resw,resx,StyleCop,targets,tasks,vbproj,xml,xsd}]
indent_style=space
indent_size=2
tab_width=2
[*.proto]
indent_style=space
indent_size=2
tab_width=2

1
.envrc Normal file
View file

@ -0,0 +1 @@
use asdf

13
.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
launchSettings.json
*~
*.user
Directory.Build.props
obj/
[Bb]in/
.vs/
.vscode/
.idea/
_ReSharper.Caches/
node_modules/

51
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,51 @@
stages:
- build
default:
before_script:
- curl -sL https://deb.nodesource.com/setup_15.x | bash -
- apt-get install -y nodejs
build:
image: mcr.microsoft.com/dotnet/sdk:5.0
stage: build
script:
# Set up the environment.
- npx npm install --ci
- npx commitlint-gitlab-ci -x @commitlint/config-conventional
# Build and test everything.
- dotnet restore
- dotnet build
#- 'dotnet test --test-adapter-path:. --logger:"junit;LogFilePath=../artifacts/{assembly}-test-result.xml;MethodFormat=Default;FailureBodyFormat=Verbose" --collect:"XPlat Code Coverage"'
# Summarize the output for Gitlab CI reporting.
#- dotnet new tool-manifest
#- dotnet tool install dotnet-reportgenerator-globaltool
#- dotnet tool run reportgenerator -reports:src/*/TestResults/*/coverage.cobertura.xml -targetdir:./coverage "-reporttypes:Cobertura;TextSummary"
#- grep "Line coverage" coverage/Summary.txt
# Perform the release.
- npx semantic-release
rules:
- if: '$CI_COMMIT_TITLE =~ /^chore\(release\)/'
when: never
- if: '$CI_COMMIT_TAG'
when: never
- if: '$CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH'
when: never
- when: on_success
artifacts:
when: always
paths:
- ./**/*test-result.xml
- ./coverage/Cobertura.xml
- ./coverage/Summary.*
- ./**/*.nupkg
reports:
junit:
- ./**/*test-result.xml
cobertura:
- ./coverage/Cobertura.xml

4
.husky/commit-msg Executable file
View file

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx --no-install commitlint --edit $1

3
.tool-versions Normal file
View file

@ -0,0 +1,3 @@
dotnet-core 5.0.100
yarn 1.22.10
nodejs 15.0.1

21
LICENSE.txt Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) Moonfire Games
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

71
MfGames.ToolBuilder.sln Normal file
View file

@ -0,0 +1,71 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 15
VisualStudioVersion = 15.0.26124.0
MinimumVisualStudioVersion = 15.0.26124.0
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{9C845D9A-B359-43B3-AE9E-B84CE945AF21}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MfGames.ToolBuilder", "src\MfGames.ToolBuilder\MfGames.ToolBuilder.csproj", "{5253E2A6-9565-45AF-92EA-1BFD3A63AC23}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SampleTool", "tests\SampleTool\SampleTool.csproj", "{0A12108A-9E00-41E9-8935-F4AA7D6208FB}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{4CE102F8-5C70-4696-B85F-93BB10034918}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MfGames.ToolBuilder.Tests", "tests\MfGames.ToolBuilder.Tests\MfGames.ToolBuilder.Tests.csproj", "{77DDD61F-AF57-4AE7-A2A8-08C61A630A9A}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{5253E2A6-9565-45AF-92EA-1BFD3A63AC23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5253E2A6-9565-45AF-92EA-1BFD3A63AC23}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5253E2A6-9565-45AF-92EA-1BFD3A63AC23}.Debug|x64.ActiveCfg = Debug|Any CPU
{5253E2A6-9565-45AF-92EA-1BFD3A63AC23}.Debug|x64.Build.0 = Debug|Any CPU
{5253E2A6-9565-45AF-92EA-1BFD3A63AC23}.Debug|x86.ActiveCfg = Debug|Any CPU
{5253E2A6-9565-45AF-92EA-1BFD3A63AC23}.Debug|x86.Build.0 = Debug|Any CPU
{5253E2A6-9565-45AF-92EA-1BFD3A63AC23}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5253E2A6-9565-45AF-92EA-1BFD3A63AC23}.Release|Any CPU.Build.0 = Release|Any CPU
{5253E2A6-9565-45AF-92EA-1BFD3A63AC23}.Release|x64.ActiveCfg = Release|Any CPU
{5253E2A6-9565-45AF-92EA-1BFD3A63AC23}.Release|x64.Build.0 = Release|Any CPU
{5253E2A6-9565-45AF-92EA-1BFD3A63AC23}.Release|x86.ActiveCfg = Release|Any CPU
{5253E2A6-9565-45AF-92EA-1BFD3A63AC23}.Release|x86.Build.0 = Release|Any CPU
{0A12108A-9E00-41E9-8935-F4AA7D6208FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0A12108A-9E00-41E9-8935-F4AA7D6208FB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0A12108A-9E00-41E9-8935-F4AA7D6208FB}.Debug|x64.ActiveCfg = Debug|Any CPU
{0A12108A-9E00-41E9-8935-F4AA7D6208FB}.Debug|x64.Build.0 = Debug|Any CPU
{0A12108A-9E00-41E9-8935-F4AA7D6208FB}.Debug|x86.ActiveCfg = Debug|Any CPU
{0A12108A-9E00-41E9-8935-F4AA7D6208FB}.Debug|x86.Build.0 = Debug|Any CPU
{0A12108A-9E00-41E9-8935-F4AA7D6208FB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0A12108A-9E00-41E9-8935-F4AA7D6208FB}.Release|Any CPU.Build.0 = Release|Any CPU
{0A12108A-9E00-41E9-8935-F4AA7D6208FB}.Release|x64.ActiveCfg = Release|Any CPU
{0A12108A-9E00-41E9-8935-F4AA7D6208FB}.Release|x64.Build.0 = Release|Any CPU
{0A12108A-9E00-41E9-8935-F4AA7D6208FB}.Release|x86.ActiveCfg = Release|Any CPU
{0A12108A-9E00-41E9-8935-F4AA7D6208FB}.Release|x86.Build.0 = Release|Any CPU
{77DDD61F-AF57-4AE7-A2A8-08C61A630A9A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{77DDD61F-AF57-4AE7-A2A8-08C61A630A9A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{77DDD61F-AF57-4AE7-A2A8-08C61A630A9A}.Debug|x64.ActiveCfg = Debug|Any CPU
{77DDD61F-AF57-4AE7-A2A8-08C61A630A9A}.Debug|x64.Build.0 = Debug|Any CPU
{77DDD61F-AF57-4AE7-A2A8-08C61A630A9A}.Debug|x86.ActiveCfg = Debug|Any CPU
{77DDD61F-AF57-4AE7-A2A8-08C61A630A9A}.Debug|x86.Build.0 = Debug|Any CPU
{77DDD61F-AF57-4AE7-A2A8-08C61A630A9A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{77DDD61F-AF57-4AE7-A2A8-08C61A630A9A}.Release|Any CPU.Build.0 = Release|Any CPU
{77DDD61F-AF57-4AE7-A2A8-08C61A630A9A}.Release|x64.ActiveCfg = Release|Any CPU
{77DDD61F-AF57-4AE7-A2A8-08C61A630A9A}.Release|x64.Build.0 = Release|Any CPU
{77DDD61F-AF57-4AE7-A2A8-08C61A630A9A}.Release|x86.ActiveCfg = Release|Any CPU
{77DDD61F-AF57-4AE7-A2A8-08C61A630A9A}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{5253E2A6-9565-45AF-92EA-1BFD3A63AC23} = {9C845D9A-B359-43B3-AE9E-B84CE945AF21}
{0A12108A-9E00-41E9-8935-F4AA7D6208FB} = {4CE102F8-5C70-4696-B85F-93BB10034918}
{77DDD61F-AF57-4AE7-A2A8-08C61A630A9A} = {4CE102F8-5C70-4696-B85F-93BB10034918}
EndGlobalSection
EndGlobal

File diff suppressed because it is too large Load diff

7
NuGet.Config Normal file
View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
<add key="mfgames" value="https://www.myget.org/F/mfgames/api/v3/index.json" protocolVersion="3" />
</packageSources>
</configuration>

4
README.md Normal file
View file

@ -0,0 +1,4 @@
Nitride CIL
===========
A static site generator based on the [Gallium ECS](https://gitlab.com/mfgames-cil/gallium-cil/).

3
commitlint.config.js Normal file
View file

@ -0,0 +1,3 @@
module.exports = {
extends: ["@commitlint/config-conventional"],
};

12304
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

21
package.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "mfgames-toolbuilder",
"version": "1.0.0",
"private": true,
"scripts": {
"prepare": "husky install"
},
"devDependencies": {
"@commitlint/cli": "^13.1.0",
"@commitlint/config-conventional": "^13.1.0",
"@semantic-release/changelog": "^5.0.1",
"@semantic-release/git": "^9.0.0",
"@semantic-release/gitlab": "^6.2.2",
"@semantic-release/npm": "^7.1.3",
"commitlint-gitlab-ci": "^0.0.4",
"husky": "^7.0.2",
"semantic-release": "^17.4.7",
"semantic-release-dotnet": "^1.0.0",
"semantic-release-nuget": "^1.1.0"
}
}

20
release.config.js Normal file
View file

@ -0,0 +1,20 @@
module.exports = {
branches: ["main"],
message: "chore(release): v${nextRelease.version}\n\n${nextRelease.notes}",
plugins: [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/npm",
"semantic-release-dotnet",
[
"semantic-release-nuget",
{
packArguments: ["--include-symbols", "--include-source"],
pushFiles: ["src/*/bin/Debug/*.nupkg"],
},
],
"@semantic-release/changelog",
"@semantic-release/git",
"@semantic-release/gitlab",
],
};

View file

@ -0,0 +1,126 @@
using System;
using System.CommandLine;
using System.IO;
using Microsoft.Extensions.Configuration;
namespace MfGames.ToolBuilder
{
/// <summary>
/// A utility class for handling `--config` options.
/// </summary>
public class ConfigToolService
{
public ConfigToolService()
{
this.ConfigOption = new Option<string[]>(
"--config",
"Configuration file to use for settings, otherwise a default will be used.",
ArgumentArity.OneOrMore)
{
AllowMultipleArgumentsPerToken = false,
};
this.ConfigOption.AddAlias("-c");
}
/// <summary>
/// Gets the common option for setting configuration files.
/// </summary>
public Option<string[]> ConfigOption { get; }
/// <summary>
/// Gets the default configuration file.
/// </summary>
public FileInfo DefaultConfigFile => new(this.DefaultConfigPath);
/// <summary>
/// Gets the default configuration path.
/// </summary>
public string DefaultConfigPath
{
get
{
// If we don't have an internal name, blow up.
string? internalName = this.InternalName;
if (internalName == null)
{
throw new ApplicationException(
"Cannot determine the default configuration path unless internal name has been set.");
}
// Figure out the path to the default configuration. This is
// something like:
// $HOME/.config/ApplicationName/Settings.json
string configDirectory = Environment
.GetFolderPath(Environment.SpecialFolder.ApplicationData);
string appDirectory = Path.Combine(
configDirectory,
internalName);
string configPath = Path.Combine(appDirectory, "Settings.json");
return configPath;
}
}
/// <summary>
/// Gets or sets the name of the application. This is used to figure out
/// the name of the configuration file and what is shown on the screen.
/// </summary>
public string? InternalName { get; set; }
/// <summary>
/// Adds the common options to the command.
/// </summary>
/// <param name="root"></param>
public void AddOptions(RootCommand root)
{
root.AddGlobalOption(this.ConfigOption);
}
/// <summary>
/// Sets up logging based on the global settings.
/// </summary>
/// <param name="builder">The configuration builder to use.</param>
/// <param name="arguments">The arguments to the command.</param>
public void Configure(IConfigurationBuilder builder, string[] arguments)
{
// In general, we don't use a local AppSettings.json automatically
// but prefer configuration in the $HOME/.config folder instead.
// However, if the user gives a configuration setting, we use that
// no matter where we want to put it.
string[] configs = GlobalOptionHelper.GetArgumentValue(
this.ConfigOption,
arguments,
Array.Empty<string>());
// If we don't have anything, then use the default.
if (configs.Length == 0)
{
builder.AddJsonFile(this.DefaultConfigPath, true, true);
return;
}
// Otherwise, use the default files.
foreach (var config in configs)
{
builder.AddJsonFile(config, true, true);
}
}
/// <summary>
/// Sets the internal name of the application, used for the
/// configuration path.
/// </summary>
/// <param name="internalName">
/// The internal name of the application.
/// </param>
/// <returns>The service for chaining operations.</returns>
public ConfigToolService WithInternalName(string internalName)
{
this.InternalName = internalName;
return this;
}
}
}

View file

@ -0,0 +1,32 @@
using System.Collections.Generic;
using System.CommandLine;
using System.CommandLine.Parsing;
using System.Linq;
namespace MfGames.ToolBuilder.Extensions
{
public static class ParseResultExtensions
{
public static List<string> ValueListForOption(
this ParseResult result,
Option<string> option)
{
string? optionValues = result.ValueForOption(option);
if (optionValues == null)
{
return new List<string>();
}
var values = optionValues
.Split(',')
.Select(x => x.Trim())
.SelectMany(x => x.Split(' '))
.Select(x => x.Trim())
.Where(x => !string.IsNullOrWhiteSpace(x))
.ToList();
return values;
}
}
}

View file

@ -0,0 +1,137 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FluentResults;
namespace MfGames.ToolBuilder.Extensions
{
public static class StringCliExtensions
{
/// <summary>
/// Parses a string and converts it into an enumeration while ignoring
/// case and allow only prefix values. If the value cannot be
/// determined, it will report it and throw an exception.
/// </summary>
/// <typeparam name="TEnum">The enumeration to parse.</typeparam>
/// <param name="input">The input value to parse.</param>
/// <param name="logger">The logger to report issues.</param>
/// <param name="label">The name of the variable or label.</param>
/// <returns></returns>
public static TEnum GetEnumFuzzy<TEnum>(
this string input,
string label)
where TEnum : struct
{
if (input.TryParseEnumFuzzy(out TEnum value))
{
return value;
}
throw new InvalidOperationException(
string.Format(
"Cannot parse unknown value {0} for {1}: {2}",
input,
label,
Enum.GetNames(typeof(TEnum))));
}
/// <summary>
/// Searches for possible list of values for a given one, handling
/// case-insensitive searching and substring searches.
/// </summary>
/// <param name="value">The value to search for.</param>
/// <param name="possible">A collection of all possible values.</param>
/// <returns>A tuple with the selected value or an error message.</returns>
public static Result<string> GetFuzzy(
this string value,
ICollection<string> possible)
{
// Look for a direct match.
List<string> found = possible
.Where(
x => string.Equals(
x,
value,
StringComparison.InvariantCultureIgnoreCase))
.ToList();
if (found.Count == 1)
{
return Result.Ok(found[0]);
}
// Look for substrings in the values.
found = possible
.Where(
x => x.Contains(
value,
StringComparison.InvariantCultureIgnoreCase))
.ToList();
return found.Count switch
{
1 => Result.Ok(found[0]),
0 => Result.Fail<string>(
string.Format(
"Cannot find \"{0}\" from possible options: {1}.",
value,
string.Join(", ", possible))),
_ => Result.Fail<string>(
string.Format(
"Found multiple matches for \"{0}\" from possible options: {1}.",
value,
string.Join(", ", found))),
};
}
/// <summary>
/// Parses a string and converts it into an enumeration while ignoring
/// case and allowing only prefix
/// values.
/// </summary>
/// <typeparam name="TEnum">The enumeration to parse.</typeparam>
/// <param name="input">The input value to parse.</param>
/// <param name="value">
/// The resulting value if parsed or default if not.
/// </param>
/// <returns>The resulting enum.</returns>
public static bool TryParseEnumFuzzy<TEnum>(
this string input,
out TEnum value)
where TEnum : struct
{
// If we have a blank, then we don't know what to do.
if (string.IsNullOrWhiteSpace(input))
{
value = default;
return false;
}
// See if we have an exact match first.
if (Enum.TryParse(input, true, out value))
{
return true;
}
// Attempt a fuzzy search.
List<string> possible = Enum.GetNames(typeof(TEnum))
.Select(e => e.ToLower())
.Where(e => e.StartsWith(input.ToLower()))
.ToList();
if (possible.Count == 1)
{
value = (TEnum)Enum.Parse(typeof(TEnum), possible[0], true);
return true;
}
// Fall back to not allowing the value.
value = default;
return false;
}
}
}

View file

@ -0,0 +1,35 @@
using System.CommandLine;
using System.CommandLine.Parsing;
namespace MfGames.ToolBuilder
{
/// <summary>
/// Helper methods for pulling out values without performing the full parse.
/// </summary>
public static class GlobalOptionHelper
{
public static TType GetArgumentValue<TType>(
Option<TType> option,
string[] arguments,
TType defaultValue)
{
var rootCommand = new RootCommand
{
option,
};
rootCommand.TreatUnmatchedTokensAsErrors = false;
ParseResult results = rootCommand.Parse(arguments);
if (!results.HasOption(option))
{
return defaultValue;
}
TType value = results.ValueForOption(option)!;
return value;
}
}
}

View file

@ -0,0 +1,14 @@
using System.CommandLine;
namespace MfGames.ToolBuilder
{
/// <summary>
/// An interface that indicates that the given command is a top-level
/// command instead of one that is included as a sub-command inside another.
/// This is used to arrange the various sub-commands using dependency
/// injection and is purely a marker interface.
/// </summary>
public interface ITopCommand : ICommand
{
}
}

View file

@ -0,0 +1,77 @@
using System;
using System.CommandLine;
using MfGames.ToolBuilder.Extensions;
using Serilog;
using Serilog.Core;
using Serilog.Events;
using Serilog.Exceptions;
namespace MfGames.ToolBuilder
{
/// <summary>
/// A service for handling logging options.
/// </summary>
public class LoggingToolService
{
public LoggingToolService()
{
this.LogLevelOption = new Option<string>(
"--log-level",
() => nameof(LogEventLevel.Information),
string.Format(
"Controls the verbosity of the output: {0}. Not case-sensitive and prefixes allowed.",
string.Join(", ", Enum.GetNames<LogEventLevel>())));
}
/// <summary>
/// Gets the common option for setting the log level.
/// </summary>
public Option<string> LogLevelOption { get; }
/// <summary>
/// Adds the common options to the command.
/// </summary>
/// <param name="root"></param>
public void AddOptions(RootCommand root)
{
root.AddGlobalOption(this.LogLevelOption);
}
/// <summary>
/// Sets up logging based on the global settings.
/// </summary>
/// <param name="arguments">The arguments to the command.</param>
public void Configure(string[] arguments)
{
// Figure out the logging level.
string level = GlobalOptionHelper.GetArgumentValue(
this.LogLevelOption,
arguments,
"Information");
LogEventLevel logLevel = level.GetEnumFuzzy<LogEventLevel>(
"log level");
// Figure out how we are going to configure the logger.
var configuration = new LoggerConfiguration();
// Create the logger and set it.
const string template =
"{Timestamp:yyyy-MM-dd HH:mm:ss} [{Level:u3}] "
+ "{Message}"
+ "{NewLine}{Exception}";
Logger logger = configuration
.Enrich.WithDemystifiedStackTraces()
.Enrich.WithExceptionDetails()
.MinimumLevel.Is(logLevel)
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Console(outputTemplate: template)
.CreateLogger();
Log.Logger = logger;
}
}
}

View file

@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<Nullable>enable</Nullable>
<Description>A framework for easily creating command line tools using System.CommandLine, Autofac, and Serilog.</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Autofac" Version="6.2.0" />
<PackageReference Include="Autofac.Extensions.DependencyInjection" Version="7.1.0" />
<PackageReference Include="ConsoleTableExt" Version="3.1.9" />
<PackageReference Include="CsvHelper" Version="27.1.1" />
<PackageReference Include="FluentResults" Version="2.5.0" />
<PackageReference Include="Glob" Version="1.1.8" />
<PackageReference Include="Humanizer.Core" Version="2.11.10" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="5.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="Roslynator.Analyzers" Version="3.2.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.CodeAnalysis.Analyzers" Version="1.1.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.Formatting.Analyzers" Version="1.2.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.Enrichers.Demystifier" Version="1.0.1" />
<PackageReference Include="Serilog.Exceptions" Version="7.0.0" />
<PackageReference Include="Serilog.Extensions.Autofac.DependencyInjection" Version="4.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="4.1.2" />
<PackageReference Include="Serilog.Sinks.Console" Version="3.1.1" />
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta1.21216.1" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,50 @@
namespace MfGames.ToolBuilder.Tables
{
/// <summary>
/// A list of the known formats. This duplicates the values in
/// `ConsoleTableBuilderFormat` in addition to adding
/// our additional formats.
/// </summary>
public enum TableFormatType
{
/// <summary>
/// Duplicates ConsoleTableBuilderFormat.Default.
/// </summary>
Default,
/// <summary>
/// Duplicates ConsoleTableBuilderFormat.MarkDown.
/// </summary>
Markdown,
/// <summary>
/// Duplicates ConsoleTableBuilderFormat.Alternative.
/// </summary>
Alternative,
/// <summary>
/// Duplicates ConsoleTableBuilderFormat.Minimal.
/// </summary>
Minimal,
/// <summary>
/// Indicates that the output should be written as a JSON structure.
/// </summary>
Json,
/// <summary>
/// Indicates that the output should be written as a PowerShell-style list.
/// </summary>
List,
/// <summary>
/// Indicates tht the output should be written as comma-separated values.
/// </summary>
Csv,
/// <summary>
/// Duplicates ConsoleTableBuilderFormat.Default.
/// </summary>
Full = Default,
}
}

View file

@ -0,0 +1,437 @@
#pragma warning disable Serilog004 // Constant MessageTemplate verifier
using System;
using System.Collections.Generic;
using System.CommandLine;
using System.CommandLine.Invocation;
using System.CommandLine.IO;
using System.Data;
using System.Globalization;
using System.IO;
using System.Linq;
using ConsoleTableExt;
using CsvHelper;
using CsvHelper.Configuration;
using FluentResults;
using GlobExpressions;
using MfGames.ToolBuilder.Extensions;
using Newtonsoft.Json;
using Serilog;
namespace MfGames.ToolBuilder.Tables
{
/// <summary>
/// Contains various utility functions for working with commands that display
/// tables.
/// </summary>
public class TableToolService
{
private readonly Command command;
private readonly ILogger logger;
private readonly Option<bool> noAlignTableOption;
private readonly Option<string> tableColumnOption;
private readonly Option<string> tableFormatOption;
private DataTable? table;
/// <summary>
/// Creates a new table CLI service with specific commands.
/// </summary>
/// <param name="logger">The logger for requests.</param>
/// <param name="command">The command to add the options to.</param>
/// <param name="table">The optional table to columns the parameters.</param>
/// <param name="defaultColumns">A list of columns to display if none are set.</param>
public TableToolService(
ILogger logger,
Command command,
DataTable? table = null,
IList<string>? defaultColumns = null)
{
// Set member variables.
this.logger = logger;
this.command = command;
this.table = table;
this.DefaultColumns = defaultColumns;
// If we have a table, then we can show the list of known columns.
string? tableColumnsDescription =
"The columns to display in the output.";
string? tableFormatDescription = string.Format(
"The fuzzy format of the table, one of: {0}.",
string.Join(", ", Enum.GetNames<TableFormatType>()));
if (table != null)
{
tableColumnsDescription = string.Format(
"The columns to display in the output, \"*\" or comma-separated list of: {0}.",
string.Join(
", ",
table.Columns.OfType<DataColumn>()
.Select(x => x.ColumnName)));
if (defaultColumns != null)
{
tableColumnsDescription += " [default: "
+ string.Join(",", defaultColumns)
+ "]";
}
}
// Create the parameters for the table.
this.tableColumnOption = new Option<string>(
"--table-columns",
tableColumnsDescription)
{
ArgumentHelpName = "column[,column...]",
};
this.noAlignTableOption = new Option<bool>(
"--no-align-table-columns",
() => false,
"If set, don't right-align numerical columns.");
this.tableFormatOption = new Option<string>(
"--table-format",
() => nameof(TableFormatType.Minimal),
tableFormatDescription)
{
ArgumentHelpName = "format",
};
// Add the options into the command.
command.AddOption(this.tableFormatOption);
command.AddOption(this.noAlignTableOption);
command.AddOption(this.tableColumnOption);
}
public delegate TableToolService Factory(
Command command,
DataTable? table = null,
IList<string>? defaultColumns = null);
public IList<string>? DefaultColumns { get; set; }
public IList<string> GetVisibleColumnNames(InvocationContext context)
{
// Do a little sanity checking on member variables.
if (this.table == null)
{
throw new NullReferenceException(
"The table field has not been defined.");
}
// Figure out the columns that the user wants to see. If they have
// not specified any, then we use the defaults.
List<string> defaultColumns =
this.DefaultColumns?.ToList() ?? new List<string>();
var optionColumns = context.ParseResult
.ValueListForOption(this.tableColumnOption)
.ToList();
List<string> tableColumns = optionColumns.Count == 0
? defaultColumns
: optionColumns;
// Expand the columns out to handle a simplified globbing where "*"
// means include all columns.
var allColumns = this.table.Columns
.Cast<DataColumn>()
.Select(x => x.ColumnName)
.ToList();
List<string> expandedNames = tableColumns
.SelectMany(x => IsMatch(allColumns, x))
.ToList();
// Do a fuzzy matching of names. This allows for a case-insensitive
// search while also allowing for prefixes (so "d" works for
// "Debug").
List<Result<string>> fuzzyResults = expandedNames
.ConvertAll(x => x.GetFuzzy(allColumns));
ToolException.ThrowIf(fuzzyResults);
var fuzzyValues = fuzzyResults
.Select(x => x.Value)
.Distinct()
.ToList();
return fuzzyValues;
}
/// <summary>
/// Determines if the column is going to be shown to the user.
/// </summary>
/// <param name="context">The context of the request.</param>
/// <param name="columnName">The column to query.</param>
/// <returns>True if the column is visible, otherwise false.</returns>
public bool IsVisible(InvocationContext context, string columnName)
{
if (this.table == null)
{
throw new InvalidOperationException(
"Cannot use IsVisible without a sample table given.");
}
IList<string> visibleColumns = this.GetVisibleColumnNames(context);
bool visible = visibleColumns
.Any(
x => x.Equals(
columnName,
StringComparison.InvariantCultureIgnoreCase));
return visible;
}
/// <summary>
/// Renders out the table using the requested formats.
/// </summary>
/// <param name="context">The context of the command.</param>
/// <param name="updatedTable">The table to use if not the initial one..</param>
public void Write(
InvocationContext context,
DataTable? updatedTable = null)
{
// Get the table and make sure we have it.
this.table = updatedTable ?? this.table;
if (this.table == null)
{
throw new InvalidOperationException(
"Cannot write out a table if not given in the constructor, updated via property, or passed into the write method.");
}
// Adjust, hide, and reorder columns.
if (!this.AdjustColumns(context))
{
throw new ToolException(
"Cannot adjust the columns of the resulting data");
}
// Pass the resulting data to the formatting code.
TableFormatType tableFormat = this.GetTableFormat(context);
switch (tableFormat)
{
case TableFormatType.Default:
case TableFormatType.Markdown:
case TableFormatType.Alternative:
case TableFormatType.Minimal:
this.WriteTable(context, tableFormat);
break;
case TableFormatType.Json:
this.WriteJson(context);
break;
case TableFormatType.List:
this.WriteList(context);
break;
case TableFormatType.Csv:
this.WriteCsv(context);
break;
default:
throw new ArgumentOutOfRangeException();
}
}
private static IEnumerable<string> IsMatch(
IEnumerable<string> columns,
string input)
{
var glob = new Glob(input, GlobOptions.CaseInsensitive);
var matches = columns
.Where(x => glob.IsMatch(x))
.ToList();
if (matches.Count > 0)
{
return matches;
}
return new[] { input };
}
private bool AdjustColumns(InvocationContext context)
{
// Figure out if we need to change the columns.
IList<string> columnNames = this.GetVisibleColumnNames(context);
if (columnNames.Count == 0)
{
this.logger.Error("There were no columns to display");
return false;
}
// Remove excessive columns.
IEnumerable<DataColumn> columnsToRemove = this.table!.Columns
.OfType<DataColumn>()
.Where(x => !columnNames.Contains(x.ColumnName))
.ToList();
foreach (DataColumn column in columnsToRemove)
{
this.table.Columns.Remove(column);
}
// Reorder the columns to match requested order.
for (int order = 0; order < columnNames.Count; order++)
{
this.table.Columns[columnNames[order]]!.SetOrdinal(order);
}
return true;
}
private TableFormatType GetTableFormat(InvocationContext context)
{
string? tableFormat =
context.ParseResult.ValueForOption(this.tableFormatOption)
?? nameof(TableFormatType.Minimal);
if (!tableFormat.TryParseEnumFuzzy(out TableFormatType value))
{
throw new InvalidOperationException(
"Unknown table format '"
+ tableFormat
+ "'. Must be one of: "
+ string.Join(", ", Enum.GetNames<TableFormatType>()));
}
return value;
}
/// <summary>
/// Writes out the contents of the table as CSV.
/// </summary>
private void WriteCsv(InvocationContext context)
{
var configuration =
new CsvConfiguration(CultureInfo.CurrentCulture);
var stringWriter = new StringWriter();
var csv = new CsvWriter(stringWriter, configuration);
foreach (DataColumn column in
this.table!.Columns.OfType<DataColumn>())
{
csv.WriteField(column.ColumnName);
}
csv.NextRecord();
foreach (DataRow row in this.table.AsEnumerable())
{
foreach (DataColumn column in this.table.Columns
.OfType<DataColumn>())
{
csv.WriteField(Convert.ToString(row[column]));
}
csv.NextRecord();
}
context.Console.Out.Write(stringWriter.ToString());
}
/// <summary>
/// Writes out the contents of the table as JSON.
/// </summary>
private void WriteJson(InvocationContext context)
{
context.Console.Out.WriteLine(
JsonConvert.SerializeObject(this.table));
}
/// <summary>
/// Writes out the contents of the table in a PowerShell-like list.
/// </summary>
private void WriteList(InvocationContext context)
{
int labelWidth = this.table!.Columns
.OfType<DataColumn>()
.Max(x => x.ColumnName.Length);
bool first = true;
foreach (DataRow row in this.table.AsEnumerable())
{
if (first)
{
first = false;
}
else
{
context.Console.Out.WriteLine();
}
for (int i = 0; i < this.table.Columns.Count; i++)
{
context.Console.Out.WriteLine(
string.Format(
"{0} : {1}",
this.table.Columns[i]
.ColumnName.PadRight(labelWidth),
row[i]));
}
}
}
private void WriteTable(
InvocationContext context,
TableFormatType tableFormat)
{
// Build the table from options.
ConsoleTableBuilder builder = ConsoleTableBuilder
.From(this.table)
.WithFormat((ConsoleTableBuilderFormat)tableFormat);
// We default to aligning numerical columns to the right.
bool noAlign =
context.ParseResult.ValueForOption(this.noAlignTableOption);
if (!noAlign)
{
builder.WithTextAlignment(
this.table!.Columns
.OfType<DataColumn>()
.Select(
(column, index) => (Index: index,
Numeric: column.DataType.Name switch
{
nameof(Byte) => true,
nameof(Int16) => true,
nameof(Int32) => true,
nameof(Int64) => true,
nameof(Decimal) => true,
_ => false,
}))
.Where(x => x.Numeric)
.ToDictionary(x => x.Index, x => TextAligntment.Right));
}
// Write out the results.
context.Console.Out.Write(builder.Export().ToString());
}
}
}

View file

@ -0,0 +1,151 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Autofac;
using Autofac.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Serilog;
namespace MfGames.ToolBuilder
{
/// <summary>
/// A builder pattern for creating the tool. This wraps much of the hosting
/// infrastructure with some opinionated decisions and reduces the amount of
/// boilerplate needed to configure the tool.
/// </summary>
public class ToolBuilder
{
private readonly string[] arguments;
private readonly ConfigToolService configService;
private readonly IHostBuilder hostBuilder;
private readonly LoggingToolService loggingService;
public ToolBuilder(
string applicationName,
string internalName,
string[] arguments)
{
// Create our various services.
this.arguments = arguments;
this.ApplicationName = applicationName;
this.InternalName = internalName;
this.configService = new ConfigToolService()
.WithInternalName(this.InternalName);
this.loggingService = new LoggingToolService();
// Set up logging first so we can report the loading process. This
// sets up the Serilog.Log.Logger which means we can use that for
// everything beyond this point.
this.loggingService.Configure(arguments);
// Start up the basic configuration.
this.hostBuilder = Host
.CreateDefaultBuilder(arguments)
.UseConsoleLifetime()
.ConfigureAppConfiguration(this.ConfigureAppConfiguration)
.UseSerilog()
.UseServiceProviderFactory(new AutofacServiceProviderFactory())
.ConfigureServices(this.ConfigureServices)
.ConfigureContainer<ContainerBuilder>(this.ConfigureContainer);
}
/// <summary>
/// Gets the human-readable name of the application.
/// </summary>
public string ApplicationName { get; }
/// <summary>
/// Gets the internal name of the application.
/// </summary>
public string InternalName { get; }
public static ToolBuilder Create(
string applicationName,
string internalName,
string[] arguments)
{
return new ToolBuilder(applicationName, internalName, arguments);
}
public ToolBuilder ConfigureContainer(
Action<ContainerBuilder> configure)
{
this.hostBuilder.ConfigureContainer(configure);
return this;
}
public ToolBuilder ConfigureServices(
Action<IServiceCollection> configure)
{
this.hostBuilder.ConfigureServices(configure);
return this;
}
/// <summary>
/// Finishes building the tool, parses the arguments, and runs the
/// command.
/// </summary>
/// <returns>An error code, 0 for successful, otherwise false.</returns>
public async Task<int> RunAsync()
{
try
{
await this.hostBuilder
.RunConsoleAsync()
.ConfigureAwait(false);
}
catch (Exception exception)
{
Log.Fatal(
exception,
"There was a problem running the command: {Arguments}",
this.arguments);
return Environment.ExitCode == 0 ? 1 : Environment.ExitCode;
}
// Get the exit code and return it.
return Environment.ExitCode;
}
private void ConfigureAppConfiguration(
HostBuilderContext context,
IConfigurationBuilder builder)
{
builder.SetBasePath(Directory.GetCurrentDirectory());
this.configService.Configure(builder, this.arguments);
}
private void ConfigureContainer(
HostBuilderContext context,
ContainerBuilder builder)
{
// We want to get logging up and running as soon as possible. We
// also hook up the logging to the process exit in an attempt to
// make sure the logger is properly flushed before exiting.
builder.RegisterInstance(Log.Logger).As<ILogger>().SingleInstance();
AppDomain.CurrentDomain.ProcessExit +=
(_, _) => Log.CloseAndFlush();
// Register the components required to make the CLI work.
builder.RegisterModule<ToolBuilderModule>();
}
private void ConfigureServices(
HostBuilderContext context,
IServiceCollection services)
{
services.AddAutofac();
services.AddHostedService<ToolService>();
}
}
}

View file

@ -0,0 +1,20 @@
using Autofac;
namespace MfGames.ToolBuilder
{
/// <summary>
/// The Autofac module to pull in the components inside this assembly.
/// </summary>
public class ToolBuilderModule : Module
{
/// <inheritdoc />
protected override void Load(ContainerBuilder builder)
{
builder
.RegisterAssemblyTypes(this.GetType().Assembly)
.Except<ToolService>()
.AsSelf()
.AsImplementedInterfaces();
}
}
}

View file

@ -0,0 +1,70 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FluentResults;
namespace MfGames.ToolBuilder
{
public class ToolException : Exception
{
public ToolException(IEnumerable<string> messages)
: this(messages.ToArray())
{
}
public ToolException(params string[] messages)
: base("There was an exception while processing the tool.")
{
this.Messages = messages;
}
public ToolException()
: base("There was an exception while processing the tool.")
{
this.Messages = Array.Empty<string>();
}
public ToolException(string? message)
: this()
{
this.Messages = message != null
? new[] { message }
: Array.Empty<string>();
}
public ToolException(string? message, Exception? innerException)
: base(
"There was an exception while processing the tool.",
innerException)
{
this.Messages = message != null
? new[] { message }
: Array.Empty<string>();
}
public int ExitCode { get; } = 1;
public string[] Messages { get; }
public bool SuppressStackTrace { get; set; } = true;
public static void ThrowIf(IEnumerable<ResultBase> results)
{
var errors = results
.Where(x => x.IsFailed)
.ToList();
if (errors.Count == 0)
{
return;
}
var messages = errors
.SelectMany(x => x.Errors)
.Select(x => x.Message);
throw new ToolException(messages);
}
}
}

View file

@ -0,0 +1,139 @@
using System;
using System.Collections.Generic;
using System.CommandLine;
using System.CommandLine.Builder;
using System.CommandLine.Invocation;
using System.CommandLine.Parsing;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Serilog;
#pragma warning disable Serilog004
// ReSharper disable TemplateIsNotCompileTimeConstantProblem
// ReSharper disable SuspiciousTypeConversion.Global
namespace MfGames.ToolBuilder
{
/// <summary>
/// Implements the command-line shell for generating the static site.
/// </summary>
public class ToolService : IHostedService
{
private readonly IList<ITopCommand> commands;
private readonly ConfigToolService configService;
private readonly IHostApplicationLifetime lifetime;
private readonly ILogger logger;
private readonly LoggingToolService loggingService;
public ToolService(
ILogger logger,
IHostApplicationLifetime lifetime,
IList<ITopCommand> commands,
ConfigToolService configService,
LoggingToolService loggingService)
{
this.lifetime = lifetime;
this.commands = commands;
this.configService = configService;
this.loggingService = loggingService;
this.logger = logger.ForContext<ToolService>();
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
this.lifetime.ApplicationStarted
.Register(
() =>
{
Task.Run(
async () =>
await this.RunAsync().ConfigureAwait(false),
cancellationToken);
});
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
private RootCommand CreateRootCommand()
{
// Create the root command and add in the top-level commands
// underneath it.
var root = new RootCommand();
foreach (var command in this.commands)
{
root.AddCommand((Command)command);
}
// Add the universal options.
this.loggingService.AddOptions(root);
this.configService.AddOptions(root);
// Return the resulting container.
return root;
}
private void OnException(Exception exception, InvocationContext context)
{
if (exception is ToolException toolException)
{
this.logger.Fatal(toolException.Message);
foreach (var message in toolException.Messages)
{
this.logger.Fatal(message);
}
Environment.ExitCode = toolException.ExitCode;
}
else
{
this.logger.Fatal(
exception,
"Unhandled exception!");
}
}
private async Task RunAsync()
{
try
{
// Build the command tree.
RootCommand root = this.CreateRootCommand();
string[] args = Environment.GetCommandLineArgs();
// Execute the command.
this.logger.Verbose(
"Running the command-line arguments: {Arguments}",
args);
Environment.ExitCode = await new CommandLineBuilder(root)
.UseDefaults()
.UseExceptionHandler(this.OnException)
.Build()
.InvokeAsync(args)
.ConfigureAwait(false);
}
finally
{
// Stop the application once the work is done.
this.lifetime.StopApplication();
}
}
}
}

View file

@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="3.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="JunitXml.TestLogger" Version="2.1.81" />
<PackageReference Include="MfGames.TestSetup" Version="1.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.7.0" />
<PackageReference Include="Roslynator.Analyzers" Version="3.2.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.CodeAnalysis.Analyzers" Version="1.1.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.Formatting.Analyzers" Version="1.2.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit" Version="2.3.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.3.1" />
<DotNetCliToolReference Include="dotnet-xunit" Version="2.3.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MfGames.ToolBuilder\MfGames.ToolBuilder.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="MfGames.TestSetup, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null">
<HintPath>bin\Debug\net5.0\MfGames.TestSetup.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View file

@ -0,0 +1,151 @@
using System.Collections.Generic;
using System.CommandLine;
using System.CommandLine.Invocation;
using System.CommandLine.Parsing;
using System.Data;
using MfGames.TestSetup;
using MfGames.ToolBuilder.Tables;
using Xunit;
using Xunit.Abstractions;
namespace MfGames.ToolBuilder.Tests
{
public class TableToolServiceTests : TestBase<ToolBuilderTestContext>
{
private readonly DataTable table;
public TableToolServiceTests(ITestOutputHelper output)
: base(output)
{
// Create the table structure.
this.table = new DataTable();
this.table.Columns.Add("DefaultString", typeof(string));
this.table.Columns.Add("DefaultInt32", typeof(int));
this.table.Columns.Add("HiddenString", typeof(string));
this.table.Rows.Add("Row 1", 1, "Hidden 1");
this.table.Rows.Add("Row 2", 10, "Hidden 2");
this.table.Rows.Add("Row 3", 100, "Hidden 3");
}
[Fact]
public void ChooseAll()
{
// Set up the test.
using ToolBuilderTestContext context = this.CreateContext();
// Create the command with the table.
(var service, InvocationContext invoke) = this.InvokeCommand(
context,
"--table-columns",
"*");
// Verify the results.
Assert.Equal(
new[]
{
"DefaultString",
"DefaultInt32",
"HiddenString",
},
service.GetVisibleColumnNames(invoke));
Assert.True(service.IsVisible(invoke, "DefaultString"));
Assert.True(service.IsVisible(invoke, "DefaultInt32"));
Assert.True(service.IsVisible(invoke, "HiddenString"));
}
[Fact]
public void ChooseExactMatch()
{
// Set up the test.
using ToolBuilderTestContext context = this.CreateContext();
// Create the command with the table.
(var service, InvocationContext invoke) = this.InvokeCommand(
context,
"--table-columns",
"DefaultInt32");
// Verify the results.
Assert.Equal(
new[]
{
"DefaultInt32",
},
service.GetVisibleColumnNames(invoke));
Assert.False(service.IsVisible(invoke, "DefaultString"));
Assert.True(service.IsVisible(invoke, "DefaultInt32"));
Assert.False(service.IsVisible(invoke, "HiddenString"));
}
[Fact]
public void ChooseGlob()
{
// Set up the test.
using ToolBuilderTestContext context = this.CreateContext();
// Create the command with the table.
(var service, InvocationContext invoke) = this.InvokeCommand(
context,
"--table-columns",
"*string");
// Verify the results.
Assert.Equal(
new[]
{
"DefaultString",
"HiddenString",
},
service.GetVisibleColumnNames(invoke));
Assert.True(service.IsVisible(invoke, "DefaultString"));
Assert.False(service.IsVisible(invoke, "DefaultInt32"));
Assert.True(service.IsVisible(invoke, "HiddenString"));
}
[Fact]
public void DefaultColumns()
{
// Set up the test.
using ToolBuilderTestContext context = this.CreateContext();
// Create the command with the table.
(var service, InvocationContext invoke) =
this.InvokeCommand(context);
// Verify the results.
Assert.Equal(
new[]
{
"DefaultString",
"DefaultInt32",
},
service.GetVisibleColumnNames(invoke));
Assert.True(service.IsVisible(invoke, "DefaultString"));
Assert.True(service.IsVisible(invoke, "DefaultInt32"));
Assert.False(service.IsVisible(invoke, "HiddenString"));
}
private (TableToolService service, InvocationContext invoke)
InvokeCommand(
ToolBuilderTestContext context,
params string[] arguments)
{
TableToolService.Factory serviceFactory =
context.Resolve<TableToolService.Factory>();
var command = new RootCommand();
TableToolService service = serviceFactory(
command,
this.table,
new List<string>
{
"DefaultString",
"DefaultInt32",
});
ParseResult results = command.Parse(arguments);
var invoke = new InvocationContext(results);
return (service, invoke);
}
}
}

View file

@ -0,0 +1,16 @@
using Autofac;
using MfGames.TestSetup;
namespace MfGames.ToolBuilder.Tests
{
public class ToolBuilderTestContext : TestContext
{
/// <inheritdoc />
protected override void ConfigureContainer(ContainerBuilder builder)
{
base.ConfigureContainer(builder);
builder.RegisterModule<ToolBuilderModule>();
}
}
}

View file

@ -0,0 +1,38 @@
using System;
using System.CommandLine;
using System.CommandLine.Invocation;
using System.Threading.Tasks;
using MfGames.ToolBuilder;
namespace SampleTool
{
public class CrashTopCommand : Command, ITopCommand, ICommandHandler
{
private readonly Option<bool> messyOption;
/// <inheritdoc />
public CrashTopCommand()
: base("crash", "Crash the application with an exception.")
{
this.Handler = this;
this.messyOption = new Option<bool>("--messy");
this.AddOption(this.messyOption);
}
/// <inheritdoc />
public Task<int> InvokeAsync(InvocationContext context)
{
bool messy = context.ParseResult.ValueForOption(this.messyOption);
if (messy)
{
throw new Exception(
"This command crashed messily as requested.");
}
throw new ToolException("This command crashed as requested.");
}
}
}

View file

@ -0,0 +1,27 @@
using System.Threading.Tasks;
using Autofac;
using MfGames.ToolBuilder;
namespace SampleTool
{
public static class Program
{
public static async Task<int> Main(string[] args)
{
return await ToolBuilder
.Create(
"Sample Application",
"SampleApplication",
args)
.ConfigureContainer(ConfigureContainer)
.RunAsync();
}
private static void ConfigureContainer(ContainerBuilder builder)
{
builder.RegisterModule<SampleToolModule>();
}
}
}

View file

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.CommandLine" Version="2.0.0-beta1.21216.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MfGames.ToolBuilder\MfGames.ToolBuilder.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,19 @@
using Autofac;
namespace MfGames.ToolBuilder
{
/// <summary>
/// The Autofac module to pull in the components inside this assembly.
/// </summary>
public class SampleToolModule : Module
{
/// <inheritdoc />
protected override void Load(ContainerBuilder builder)
{
builder
.RegisterAssemblyTypes(this.GetType().Assembly)
.AsSelf()
.AsImplementedInterfaces();
}
}
}

View file

@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
using System.CommandLine;
using System.CommandLine.Invocation;
using System.CommandLine.IO;
using System.Data;
using System.Threading.Tasks;
using MfGames.ToolBuilder;
using MfGames.ToolBuilder.Tables;
namespace SampleTool
{
public class TableTopCommand : Command, ITopCommand, ICommandHandler
{
private readonly DataTable table;
private readonly TableToolService tableService;
/// <inheritdoc />
public TableTopCommand(TableToolService.Factory tableService)
: base("table", "Display a table.")
{
// Create the table structure.
this.table = new DataTable();
this.table.Columns.Add("DefaultString", typeof(string));
this.table.Columns.Add("DefaultInt32", typeof(int));
this.table.Columns.Add("HiddenString", typeof(string));
// Create the table service for formatting and displaying results.
this.tableService = tableService(
this,
this.table,
new List<string>
{
"DefaultString",
"DefaultInt32",
});
// This class handles the command.
this.Handler = this;
}
/// <inheritdoc />
public Task<int> InvokeAsync(InvocationContext context)
{
this.table.Rows.Add("Row 1", 1, "Hidden 1");
this.table.Rows.Add("Row 2", 10, "Hidden 2");
this.table.Rows.Add("Row 3", 100, "Hidden 3");
this.tableService.Write(context);
return Task.FromResult(0);
}
}
}