1
0
mirror of https://github.com/XFox111/Shelldon.git synced 2026-04-22 07:08:00 +03:00

init: initial commit

This commit is contained in:
2025-08-20 06:17:02 +00:00
commit 815e6f7342
71 changed files with 3418 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/dotnet
{
"name": "C# (.NET)",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/dotnet:1-9.0-bookworm"
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [5000, 5001],
// "portsAttributes": {
// "5001": {
// "protocol": "https"
// }
// }
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "dotnet restore",
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}
+12
View File
@@ -0,0 +1,12 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for more information:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
# https://containers.dev/guide/dependabot
version: 2
updates:
- package-ecosystem: "devcontainers"
directory: "/"
schedule:
interval: weekly
+487
View File
@@ -0,0 +1,487 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from `dotnet new gitignore`
# dotenv files
.env
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
.idea/
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# Mac bundle stuff
*.dmg
*.app
# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# Vim temporary swap files
*.swp
# NativeAOT build artifacts
core
+1
View File
@@ -0,0 +1 @@
{}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Eugene Fox
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.
+157
View File
@@ -0,0 +1,157 @@
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Shelldon;
using Shelldon.Attributes;
using Shelldon.Logging;
using Shelldon.Middleware;
using Shelldon.Services;
using Shelldon.Utils;
ShellApplicationBuilder builder = ShellApplication.CreateBuilder(args);
builder.Logging.SetMinimumLevel(LogLevel.Trace);
builder.Services.AddSingleton<TestService>();
ShellApplication app = builder.Build();
app.UseConsoleLogger();
app
.UseGlobalOptions()
.UseHelpBanner()
.MapHelpOption()
.MapVersionOption("version");
app.MapCommand((ILogger<Program> logger, CommandContext context) =>
{
logger.LogTrace("This is a trace log message.");
logger.LogDebug("This is a debug log message.");
logger.LogInformation("This is an information log message.");
logger.LogWarning("This is a warning log message.");
logger.LogError("This is an error log message.");
logger.LogCritical("This is a critical log message.");
Console.WriteLine("This is default command.");
});
app.MapCommand("serviceprovider", (IServiceProvider services) => Console.WriteLine(services.GetRequiredService<TestService>().Message));
app.MapCommand("service", (TestService testService) => Console.WriteLine(testService.Message));
app.MapCommand("test", () => Console.WriteLine("Test command"));
app.MapCommand("arg1", (bool? isTrue) => Console.WriteLine("The Universe says: " + (isTrue == true ? "Yes" : "No")));
app.MapCommand("arg3", (Guid id, int count, decimal price) => Console.WriteLine($"ID: {id}, Count: {count}, Price: {price}"));
app.MapCommand("mixed-args", ([FromArgument] Guid id, [FromOption(ShortNames = "c")] int count, TestService service, decimal price) => Console.WriteLine($"ID: {id}, Count: {count}, Service Message: {service.Message}, Price: {price}"));
app.MapCommand("string", ([FromArgument] string text) => Console.WriteLine($"Simon says: '{text}'"));
app.MapCommand("arg-order", ([FromArgument(Order = 2)] string arg2, [FromArgument(Order = 3)] string arg3, [FromArgument(Order = 1)] string arg1) =>
{
Console.WriteLine($"Arg1: {arg1}, Arg2: {arg2}, Arg3: {arg3}");
});
app.MapCommand("arg-array", ([FromArgument] string arg1, [FromArgument(Order = 1, Description = "Array")] string[] arg2, [FromArgument, Hidden] string arg3, [FromArgument] string arg4) => Console.WriteLine($"Arg1: {arg1}, Arg2: [{string.Join(", ", arg2)}], Arg3: {arg3}, Arg4: {arg4}"));
app.MapCommand("help", (CommandTreeProvider treeProvider) =>
{
// Display Usage: filename command [arguments]
Console.WriteLine($"Usage: ");
});
app.MapCommand("arg-test", ([FromArgument] int[] array) =>
{
// Console.WriteLine($"count: {count}");
// Console.WriteLine($"price: {price}");
Console.WriteLine($"array: [{string.Join(", ", array)}]");
});
app.MapCommand("validation", (
[FromOption, Required, MinLength(5), MaxLength(10)] string name,
[FromArgument, Range(1, 100)] int count,
[FromArgument] double price
) =>
{
Console.WriteLine("All validation checks passed.");
});
app.MapCommand("test1", ([FromArgument] Guid id, string?[]? name, int count, [Hidden] decimal price, [Hidden] bool accept, TestService service) =>
{
Console.WriteLine($"ID: {id}, Name: {name}, Count: {count}, Price: {price}, Accept: {accept}, Service Message: {service.Message}");
});
/* app.Use(async (next) =>
{
Console.WriteLine("Waiting for 2 seconds before executing the next middleware...");
await Task.Delay(2000);
await next();
}); */
app.MapCommand("stopwatch", async (CancellationToken token, int time, bool noToken) =>
{
Console.WriteLine("Use Ctrl+C to stop the stopwatch.");
int elapsedSeconds = 0;
Console.WriteLine(noToken);
do
{
Console.WriteLine($"Elapsed seconds: {elapsedSeconds} (cancellation requested: {token.IsCancellationRequested})");
await Task.Delay(1000);
}
while ((!token.IsCancellationRequested || noToken) && ++elapsedSeconds < time);
if (token.IsCancellationRequested)
Console.WriteLine("Stopwatch stopped by user.");
else
Console.WriteLine("Stopwatch completed.");
return 1;
});
app.MapSection("docker", docker =>
{
docker.MapGlobalOption("test", "t", next =>
{
Console.WriteLine("Pre-action: --test");
next();
Console.WriteLine("Post-action: --test");
})
.Scoped();
docker.MapSection("compose", compose =>
{
compose.WithDescription("Define and run multi-container applications with Docker");
compose.WithExample("docker compose up -d --build");
compose.MapCommand(() => Console.WriteLine("Docker Compose Default Command Executed"))
.WithDescription("Run docker compose command")
.WithExample("docker compose");
compose.MapCommand("up", () => Console.WriteLine("Docker Compose Up Command Executed"))
.WithDescription("Create and start containers");
compose.MapCommand("down", () => Console.WriteLine("Docker Compose Down Command Executed"))
.WithDescription("Stop and remove containers, networks");
compose.MapCommand("error", () => 15)
.WithDescription("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.");
});
docker.MapCommand("build", (bool test) => Console.WriteLine($"Docker Build Command Executed ({test})"));
docker.MapCommand(([FromArgument] string imageName, bool test) =>
{
Console.WriteLine($"Docker Image Command Executed for Image: {imageName}.");
});
});
app.Use((context, next) =>
{
Console.WriteLine("Resolution path:".FormatConsole(false, ConsoleForegroundColor.BrightWhite));
Console.WriteLine(string.Join(" -> ", [.. context.ResolutionPath.Select(x => x.Name), context.Command?.Name ?? "[X]"]).FormatConsole(false, ConsoleForegroundColor.BrightWhite));
next();
});
app.Run();
public class TestService(CommandContext context)
{
private readonly CommandContext _context = context;
public string Message => $"Hello from TestService! (Current command: {_context.CommandName})";
}
+18
View File
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Shelldon\Shelldon.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Cocona" Version="2.2.0" />
</ItemGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+7
View File
@@ -0,0 +1,7 @@
{
"Logging": {
"LogLevel": {
"Default": "Trace"
}
}
}
+48
View File
@@ -0,0 +1,48 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shelldon.Console", "Shelldon.Console\Shelldon.Console.csproj", "{E52CB7EF-184E-4773-ACC1-C59CF905F79E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shelldon", "Shelldon\Shelldon.csproj", "{39BD0485-E4E3-45CF-BB3C-7B9424F7149D}"
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(ProjectConfigurationPlatforms) = postSolution
{E52CB7EF-184E-4773-ACC1-C59CF905F79E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E52CB7EF-184E-4773-ACC1-C59CF905F79E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E52CB7EF-184E-4773-ACC1-C59CF905F79E}.Debug|x64.ActiveCfg = Debug|Any CPU
{E52CB7EF-184E-4773-ACC1-C59CF905F79E}.Debug|x64.Build.0 = Debug|Any CPU
{E52CB7EF-184E-4773-ACC1-C59CF905F79E}.Debug|x86.ActiveCfg = Debug|Any CPU
{E52CB7EF-184E-4773-ACC1-C59CF905F79E}.Debug|x86.Build.0 = Debug|Any CPU
{E52CB7EF-184E-4773-ACC1-C59CF905F79E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E52CB7EF-184E-4773-ACC1-C59CF905F79E}.Release|Any CPU.Build.0 = Release|Any CPU
{E52CB7EF-184E-4773-ACC1-C59CF905F79E}.Release|x64.ActiveCfg = Release|Any CPU
{E52CB7EF-184E-4773-ACC1-C59CF905F79E}.Release|x64.Build.0 = Release|Any CPU
{E52CB7EF-184E-4773-ACC1-C59CF905F79E}.Release|x86.ActiveCfg = Release|Any CPU
{E52CB7EF-184E-4773-ACC1-C59CF905F79E}.Release|x86.Build.0 = Release|Any CPU
{39BD0485-E4E3-45CF-BB3C-7B9424F7149D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{39BD0485-E4E3-45CF-BB3C-7B9424F7149D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{39BD0485-E4E3-45CF-BB3C-7B9424F7149D}.Debug|x64.ActiveCfg = Debug|Any CPU
{39BD0485-E4E3-45CF-BB3C-7B9424F7149D}.Debug|x64.Build.0 = Debug|Any CPU
{39BD0485-E4E3-45CF-BB3C-7B9424F7149D}.Debug|x86.ActiveCfg = Debug|Any CPU
{39BD0485-E4E3-45CF-BB3C-7B9424F7149D}.Debug|x86.Build.0 = Debug|Any CPU
{39BD0485-E4E3-45CF-BB3C-7B9424F7149D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{39BD0485-E4E3-45CF-BB3C-7B9424F7149D}.Release|Any CPU.Build.0 = Release|Any CPU
{39BD0485-E4E3-45CF-BB3C-7B9424F7149D}.Release|x64.ActiveCfg = Release|Any CPU
{39BD0485-E4E3-45CF-BB3C-7B9424F7149D}.Release|x64.Build.0 = Release|Any CPU
{39BD0485-E4E3-45CF-BB3C-7B9424F7149D}.Release|x86.ActiveCfg = Release|Any CPU
{39BD0485-E4E3-45CF-BB3C-7B9424F7149D}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
@@ -0,0 +1,7 @@
namespace Shelldon.Attributes;
[AttributeUsage(AttributeTargets.Delegate | AttributeTargets.Method, AllowMultiple = true, Inherited = false)]
public class CommandAliasAttribute(string alias) : Attribute
{
public string Alias { get; set; } = alias;
}
@@ -0,0 +1,7 @@
namespace Shelldon.Attributes;
[AttributeUsage(AttributeTargets.Delegate | AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
public class CommandDescriptionAttribute(string description) : Attribute
{
public string Description { get; set; } = description;
}
@@ -0,0 +1,7 @@
namespace Shelldon.Attributes;
[AttributeUsage(AttributeTargets.Delegate | AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
public class CommandNameAttribute(string name) : Attribute
{
public string Name { get; } = name;
}
@@ -0,0 +1,11 @@
namespace Shelldon.Attributes;
[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)]
public class FromArgumentAttribute : Attribute
{
public string? Name { get; set; }
public string Description { get; set; } = string.Empty;
public int Order { get; set; } = 0;
}
@@ -0,0 +1,14 @@
namespace Shelldon.Attributes;
[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false)]
public class FromOptionAttribute() : Attribute
{
public string? Name { get; set; }
public string ShortNames { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public FromOptionAttribute(string name) : this() =>
Name = name;
}
@@ -0,0 +1,7 @@
namespace Shelldon.Attributes;
[AttributeUsage(AttributeTargets.Parameter, Inherited = false, AllowMultiple = false)]
public class FromServiceAttribute(object? key = null) : Attribute
{
public object? Key { get; set; } = key;
}
@@ -0,0 +1,6 @@
namespace Shelldon.Attributes;
public class HelpExampleAttribute(string example) : Attribute
{
public string Example { get; } = example;
}
+4
View File
@@ -0,0 +1,4 @@
namespace Shelldon.Attributes;
[AttributeUsage(AttributeTargets.Delegate | AttributeTargets.Method | AttributeTargets.Parameter, AllowMultiple = true, Inherited = false)]
public class HiddenAttribute : Attribute { }
+3
View File
@@ -0,0 +1,3 @@
namespace Shelldon.Attributes;
public class ScopedAttribute : Attribute { }
+119
View File
@@ -0,0 +1,119 @@
using System.Reflection;
using Shelldon.Attributes;
using Shelldon.Descriptors;
using Shelldon.Descriptors.Parameters;
using Shelldon.Utils;
namespace Shelldon.Builder;
public class CommandActionBuilder(Delegate @delegate) : ICommandActionBuilder
{
public Delegate Delegate { get; } = @delegate;
public List<Attribute> Metadata { get; } = @delegate.Method.GetCustomAttributes().ToList() ?? [];
public ICommandDescriptor Build()
{
string? name = Metadata.GetAttribute<CommandNameAttribute>()?.Name;
if (name is not null)
ValidationUtils.ValidateCliCommand(name);
name ??= "@";
List<string> aliases = [];
foreach (Attribute attribute in Metadata)
if (attribute is CommandAliasAttribute aliasAttribute)
{
ValidationUtils.ValidateCliCommand(aliasAttribute.Alias);
aliases.Add(aliasAttribute.Alias);
}
return new CommandActionDescriptor
{
Name = name,
Delegate = Delegate,
Metadata = Metadata.AsReadOnly(),
Description = Metadata.GetAttribute<CommandDescriptionAttribute>()?.Description ?? string.Empty,
Aliases = aliases.AsReadOnly(),
IsHidden = Metadata.GetAttribute<HiddenAttribute>() is not null,
ParameterDescriptors = GetParameterDescriptors(),
ReturnsExitCode = Delegate.Method.ReturnType == typeof(int) ||
Delegate.Method.ReturnType == typeof(Task<int>) ||
Delegate.Method.ReturnType == typeof(ValueTask<int>)
};
}
private ParameterDescriptorCollection GetParameterDescriptors()
{
ParameterInfo[] parameters = Delegate?.Method.GetParameters() ?? [];
List<ServiceParameterDescriptor> services = [];
List<OptionParameterDescriptor> options = [];
List<ArgumentParameterDescriptor> arguments = [];
Dictionary<string, OptionParameterDescriptor> optionsMap = [];
for (int i = 0; i < parameters.Length; i++)
{
ParameterInfo parameterInfo = parameters[i];
if (parameterInfo.GetCustomAttribute<FromArgumentAttribute>() is not null)
arguments.Add(new(parameterInfo, i));
else if (
parameterInfo.GetCustomAttribute<FromServiceAttribute>() is not null ||
parameterInfo.ParameterType.IsAssignableTo(typeof(IServiceProvider)) ||
parameterInfo.ParameterType.IsAssignableTo(typeof(CommandContext)) ||
parameterInfo.ParameterType.IsAssignableTo(typeof(CancellationToken))
)
services.Add(new(parameterInfo, i));
else if (
parameterInfo.GetCustomAttribute<FromOptionAttribute>() is not null ||
TypeUtils.IsConvertibleType(parameterInfo.ParameterType)
)
{
OptionParameterDescriptor optionDescriptor = new(parameterInfo, i);
options.Add(optionDescriptor);
MapOption(optionDescriptor, optionsMap);
}
else
services.Add(new(parameterInfo, i));
}
if (arguments.FindAll(i => i.IsCollection).Count > 1)
throw new ArgumentException("Only one array type argument is allowed.");
arguments.Sort((a, b) => a.Order.CompareTo(b.Order));
return new()
{
Arguments = arguments.AsReadOnly(),
Options = options.AsReadOnly(),
Services = services.AsReadOnly(),
OptionsMap = optionsMap.AsReadOnly()
};
}
private static void MapOption(OptionParameterDescriptor optionDescriptor, Dictionary<string, OptionParameterDescriptor> optionsMap)
{
string optionName = "--" + optionDescriptor.Name;
if (optionsMap.ContainsKey(optionName))
throw new ArgumentException($"Duplicate option name '{optionName}' found in command parameters.");
optionsMap.Add(optionName, optionDescriptor);
foreach (char shortName in optionDescriptor.ShortNames)
{
string optionShortName = "-" + shortName;
if (optionsMap.ContainsKey(optionShortName))
throw new ArgumentException($"Duplicate short option name '{optionShortName}' found in command parameters.");
optionsMap.Add(optionShortName, optionDescriptor);
}
}
}
+68
View File
@@ -0,0 +1,68 @@
using Shelldon.Attributes;
using Shelldon.Descriptors;
using Shelldon.Utils;
namespace Shelldon.Builder;
public class CommandSectionBuilder(string name) : ICommandSectionBuilder
{
public List<ICommandBuilder> Commands { get; } = [];
public List<Attribute> Metadata { get; } = [new CommandNameAttribute(name)];
public List<IGlobalOptionBuilder> GlobalOptions { get; } = [];
internal ICommandSectionDescriptor Build(bool isRoot)
{
string name = Metadata.GetAttribute<CommandNameAttribute>()?.Name
?? throw new InvalidOperationException("Section name is required.");
if (!isRoot)
ValidationUtils.ValidateCliCommand(name);
List<string> aliases = [];
foreach (Attribute attribute in Metadata)
if (attribute is CommandAliasAttribute aliasAttribute)
{
ValidationUtils.ValidateCliCommand(aliasAttribute.Alias);
aliases.Add(aliasAttribute.Alias.ToLowerInvariant());
}
List<ICommandDescriptor> commands = [];
Dictionary<string, ICommandDescriptor> routes = [];
foreach (ICommandBuilder commandBuilder in Commands)
{
ICommandDescriptor commandDescriptor = commandBuilder.Build();
commands.Add(commandDescriptor);
if (routes.ContainsKey(commandDescriptor.Name))
throw new InvalidOperationException($"Duplicate command name '{commandDescriptor.Name}' in section '{name}'.");
routes[commandDescriptor.Name] = commandDescriptor;
foreach (string alias in commandDescriptor.Aliases)
{
if (routes.ContainsKey(alias))
throw new InvalidOperationException($"Duplicate command alias '{alias}' for command '{commandDescriptor.Name}' in section '{name}'.");
routes[alias] = commandDescriptor;
}
}
return new CommandSectionDescriptor
{
Name = name.ToLowerInvariant(),
Metadata = Metadata,
Aliases = aliases.AsReadOnly(),
Description = Metadata.GetAttribute<CommandDescriptionAttribute>()?.Description ?? string.Empty,
IsHidden = Metadata.GetAttribute<HiddenAttribute>() is not null,
Commands = commands.AsReadOnly(),
Routes = routes.AsReadOnly(),
GlobalOptions = GlobalOptions.Select(o => o.Build()).ToList().AsReadOnly()
};
}
public ICommandDescriptor Build() => Build(false);
}
+45
View File
@@ -0,0 +1,45 @@
using System.ComponentModel;
using System.Reflection;
using Shelldon.Attributes;
using Shelldon.Descriptors;
using Shelldon.Utils;
namespace Shelldon.Builder;
public class GlobalOptionBuilder(string name, string shortNames, Delegate @delegate) : IGlobalOptionBuilder
{
public Delegate Delegate { get; } = @delegate;
public List<Attribute> Metadata { get; } = [.. @delegate.Method.GetCustomAttributes(), new FromOptionAttribute(name) { ShortNames = shortNames }];
public IGlobalOptionDescriptor Build()
{
FromOptionAttribute? fromOption = Metadata.GetAttribute<FromOptionAttribute>();
if (string.IsNullOrEmpty(fromOption?.Name))
throw new InvalidOperationException("Global option must have a name");
ValidationUtils.ValidateCliMember(fromOption.Name);
if (!string.IsNullOrEmpty(fromOption.ShortNames))
foreach (char shortName in fromOption.ShortNames)
if (!char.IsLetterOrDigit(shortName))
throw new ArgumentException($"Short names must be alphanumeric characters, but '{shortName}' is not.");
string description = fromOption.Description;
if (string.IsNullOrEmpty(description))
description = Metadata.GetAttribute<CommandDescriptionAttribute>()?.Description ?? string.Empty;
return new GlobalOptionDescriptor()
{
Metadata = Metadata,
Name = fromOption.Name,
Delegate = Delegate,
IsHidden = Metadata.GetAttribute<HiddenAttribute>() is not null,
Description = description,
ShortNames = fromOption.ShortNames,
IsScoped = Metadata.GetAttribute<ScopedAttribute>() is not null,
};
}
}
@@ -0,0 +1,6 @@
namespace Shelldon.Builder;
public interface ICommandActionBuilder : ICommandBuilder
{
public Delegate Delegate { get; }
}
+8
View File
@@ -0,0 +1,8 @@
using Shelldon.Descriptors;
namespace Shelldon.Builder;
public interface ICommandBuilder : IMetadataBuilder
{
ICommandDescriptor Build();
}
@@ -0,0 +1,8 @@
namespace Shelldon.Builder;
public interface ICommandSectionBuilder : ICommandBuilder
{
public List<ICommandBuilder> Commands { get; }
public List<IGlobalOptionBuilder> GlobalOptions { get; }
}
+10
View File
@@ -0,0 +1,10 @@
using Shelldon.Descriptors;
namespace Shelldon.Builder;
public interface IGlobalOptionBuilder : IMetadataBuilder
{
public Delegate Delegate { get; }
public IGlobalOptionDescriptor Build();
}
+6
View File
@@ -0,0 +1,6 @@
namespace Shelldon.Builder;
public interface IMetadataBuilder
{
public List<Attribute> Metadata { get; }
}
+152
View File
@@ -0,0 +1,152 @@
using Shelldon.Attributes;
using Shelldon.Builder;
using Shelldon.Middleware;
using Shelldon.Utils;
namespace Shelldon;
public static class CommandBuilderExtensions
{
public static ICommandBuilder WithMetadata(this ICommandBuilder builder, params Attribute[] metadata)
{
builder.Metadata.AddRange(metadata);
return builder;
}
public static ICommandBuilder WithName(this ICommandBuilder builder, string name)
{
builder.Metadata.Add(new CommandNameAttribute(name));
return builder;
}
public static T WithDescription<T>(this T builder, string description) where T : IMetadataBuilder
{
builder.Metadata.Add(new CommandDescriptionAttribute(description));
return builder;
}
public static ICommandBuilder WithAlias(this ICommandBuilder builder, string alias)
{
builder.Metadata.Add(new CommandAliasAttribute(alias));
return builder;
}
public static T Hidden<T>(this T builder) where T : IMetadataBuilder
{
builder.Metadata.Add(new HiddenAttribute());
return builder;
}
public static ShellApplication Use(this ShellApplication app, Func<CommandContext, Func<Task>, Task> middleware)
{
app.Middleware.Add(middleware);
return app;
}
public static ShellApplication Use(this ShellApplication app, Func<Func<Task>, Task> middleware)
{
app.Middleware.Add(middleware);
return app;
}
public static ShellApplication Use(this ShellApplication app, Action<CommandContext, Action> middleware)
{
app.Middleware.Add(middleware);
return app;
}
public static ShellApplication Use(this ShellApplication app, Action<Action> middleware)
{
app.Middleware.Add(middleware);
return app;
}
public static ICommandActionBuilder MapCommand(this ICommandSectionBuilder builder, Delegate command)
{
CommandActionBuilder actionBuilder = new(command);
builder.Commands.Add(actionBuilder);
return actionBuilder;
}
public static ICommandActionBuilder MapCommand(this ICommandSectionBuilder builder, string name, Delegate command)
{
CommandActionBuilder actionBuilder = new(command);
actionBuilder.Metadata.Add(new CommandNameAttribute(name));
builder.Commands.Add(actionBuilder);
return actionBuilder;
}
public static void MapSection(this ICommandSectionBuilder builder, string name, Action<ICommandSectionBuilder> section)
{
CommandSectionBuilder sectionBuilder = new(name);
section(sectionBuilder);
builder.Commands.Add(sectionBuilder);
}
public static ICommandBuilder WithExample(this ICommandBuilder builder, string example)
{
builder.Metadata.Add(new HelpExampleAttribute(example));
return builder;
}
public static IGlobalOptionBuilder MapGlobalOption(this ICommandSectionBuilder builder, string name, string shortNames, Func<CommandContext, Func<Task>, Task> action)
{
GlobalOptionBuilder globalOptionBuilder = new(name, shortNames, action);
builder.GlobalOptions.Add(globalOptionBuilder);
return globalOptionBuilder;
}
public static IGlobalOptionBuilder MapGlobalOption(this ICommandSectionBuilder builder, string name, string shortNames, Func<Func<Task>, Task> action)
{
GlobalOptionBuilder globalOptionBuilder = new(name, shortNames, action);
builder.GlobalOptions.Add(globalOptionBuilder);
return globalOptionBuilder;
}
public static IGlobalOptionBuilder MapGlobalOption(this ICommandSectionBuilder builder, string name, Func<CommandContext, Func<Task>, Task> action) =>
MapGlobalOption(builder, name, string.Empty, action);
public static IGlobalOptionBuilder MapGlobalOption(this ICommandSectionBuilder builder, string name, Func<Func<Task>, Task> action) =>
MapGlobalOption(builder, name, string.Empty, action);
public static IGlobalOptionBuilder MapGlobalOption(this ICommandSectionBuilder builder, string name, string shortNames, Action<CommandContext, Action> action)
{
GlobalOptionBuilder globalOptionBuilder = new(name, shortNames, action);
builder.GlobalOptions.Add(globalOptionBuilder);
return globalOptionBuilder;
}
public static IGlobalOptionBuilder MapGlobalOption(this ICommandSectionBuilder builder, string name, string shortNames, Action<Action> action)
{
GlobalOptionBuilder globalOptionBuilder = new(name, shortNames, action);
builder.GlobalOptions.Add(globalOptionBuilder);
return globalOptionBuilder;
}
public static IGlobalOptionBuilder MapGlobalOption(this ICommandSectionBuilder builder, string name, Action<CommandContext, Action> action) =>
MapGlobalOption(builder, name, string.Empty, action);
public static IGlobalOptionBuilder MapGlobalOption(this ICommandSectionBuilder builder, string name, Action<Action> action) =>
MapGlobalOption(builder, name, string.Empty, action);
public static IGlobalOptionBuilder WithShortNames(this IGlobalOptionBuilder builder, string shortNames)
{
builder.Metadata.GetAttribute<FromOptionAttribute>()!.ShortNames = shortNames;
return builder;
}
public static IGlobalOptionBuilder Scoped(this IGlobalOptionBuilder builder)
{
builder.Metadata.Add(new ScopedAttribute());
return builder;
}
public static ShellApplication UseDefaults(this ShellApplication app)
{
return app
.UseGlobalOptions()
.UseHelpBanner()
.MapHelpOption()
.MapVersionOption();
}
}
+70
View File
@@ -0,0 +1,70 @@
using System.Diagnostics.CodeAnalysis;
using Shelldon.Descriptors;
namespace Shelldon;
/* public class CommandContext(
ICommandActionDescriptor? descriptor,
string name,
string[] args,
List<ICommandDescriptor> resolutionChain,
List<IGlobalOptionDescriptor> globalOptions
)
{
[MemberNotNullWhen(true, nameof(Command))]
public bool IsResolved => Command is not null;
public ICommandActionDescriptor? Command { get; } = descriptor;
public string CommandName { get; } = name;
public IReadOnlyList<ICommandDescriptor> ResolutionPath { get; } = resolutionChain.AsReadOnly();
public string[] Args { get; } = args;
public bool InterruptExecution { get; set; } = false;
public bool ShellExecution { get; set; } = false;
public IReadOnlyList<IGlobalOptionDescriptor> GlobalOptions { get; } = globalOptions.AsReadOnly();
public event EventHandler<CommandResult>? ExecutionFinished;
protected internal void OnExecutionFinished(object sender, CommandResult result)
{
ExecutionFinished?.Invoke(sender, result);
}
} */
public class CommandContext(
ICommandActionDescriptor? command,
string name,
string[] args,
List<ICommandSectionDescriptor> resolutionPath,
List<IGlobalOptionDescriptor> globalOptions
)
{
[MemberNotNullWhen(true, nameof(Command))]
public bool IsResolved => Command is not null;
public ICommandActionDescriptor? Command { get; } = command;
public string CommandName { get; } = name;
public string[] Args { get; } = args;
private readonly Dictionary<int, int> _consumedArgs = [];
internal IReadOnlyDictionary<int, int> ConsumedArgs => _consumedArgs.AsReadOnly();
public IReadOnlyList<ICommandSectionDescriptor> ResolutionPath { get; } = resolutionPath.AsReadOnly();
public IReadOnlyList<IGlobalOptionDescriptor> GlobalOptions { get; } = globalOptions.AsReadOnly();
public int ExitCode { get; set; } = 0;
public bool ContinueAsShell { get; set; } = false;
internal void ConsumeArgs(int index, int count) =>
_consumedArgs[index] = count;
}
+10
View File
@@ -0,0 +1,10 @@
namespace Shelldon;
public record CommandResult(
int ExitCode,
CommandContext CommandContext,
Exception? Exception = null
)
{
public bool IsSuccess => ExitCode == 0 && Exception is null;
}
@@ -0,0 +1,25 @@
using Shelldon.Descriptors.Parameters;
namespace Shelldon.Descriptors;
public class CommandActionDescriptor : ICommandActionDescriptor
{
public required Delegate Delegate { get; init; }
public bool IsDefault => Name == "@";
public required ParameterDescriptorCollection ParameterDescriptors { get; init; }
public required string Name { get; init; }
public required string Description { get; init; }
public required IReadOnlyList<string> Aliases { get; init; }
public required bool IsHidden { get; init; }
public required bool ReturnsExitCode { get; init; }
public required IReadOnlyList<Attribute> Metadata { get; init; }
}
@@ -0,0 +1,21 @@
namespace Shelldon.Descriptors;
public class CommandSectionDescriptor : ICommandSectionDescriptor
{
public required IReadOnlyList<ICommandDescriptor> Commands { get; init; }
public required string Name { get; init; }
public required string Description { get; init; }
public required IReadOnlyList<string> Aliases { get; init; }
public required bool IsHidden { get; init; }
public required IReadOnlyList<Attribute> Metadata { get; init; }
public required IReadOnlyDictionary<string, ICommandDescriptor> Routes { get; init; }
public required IReadOnlyList<IGlobalOptionDescriptor> GlobalOptions { get; init; }
}
@@ -0,0 +1,18 @@
namespace Shelldon.Descriptors;
public class GlobalOptionDescriptor : IGlobalOptionDescriptor
{
public required Delegate Delegate { get; init; }
public required string Name { get; init; }
public required string ShortNames { get; init; }
public required string Description { get; init; }
public required bool IsHidden { get; init; }
public required IReadOnlyList<Attribute> Metadata { get; init; }
public required bool IsScoped { get; init; }
}
@@ -0,0 +1,14 @@
using Shelldon.Descriptors.Parameters;
namespace Shelldon.Descriptors;
public interface ICommandActionDescriptor : ICommandDescriptor
{
public Delegate Delegate { get; }
public bool IsDefault { get; }
public bool ReturnsExitCode { get; }
public ParameterDescriptorCollection ParameterDescriptors { get; }
}
@@ -0,0 +1,14 @@
namespace Shelldon.Descriptors;
public interface ICommandDescriptor
{
public string Name { get; }
public string Description { get; }
public IReadOnlyList<string> Aliases { get; }
public bool IsHidden { get; }
public IReadOnlyList<Attribute> Metadata { get; }
}
@@ -0,0 +1,10 @@
namespace Shelldon.Descriptors;
public interface ICommandSectionDescriptor : ICommandDescriptor
{
public IReadOnlyList<ICommandDescriptor> Commands { get; }
public IReadOnlyDictionary<string, ICommandDescriptor> Routes { get; }
public IReadOnlyList<IGlobalOptionDescriptor> GlobalOptions { get; }
}
@@ -0,0 +1,18 @@
namespace Shelldon.Descriptors;
public interface IGlobalOptionDescriptor
{
public Delegate Delegate { get; }
public string Name { get; }
public string ShortNames { get; }
public string Description { get; }
public bool IsHidden { get; }
public bool IsScoped { get; }
public IReadOnlyList<Attribute> Metadata { get; }
}
@@ -0,0 +1,33 @@
using System.Reflection;
using Shelldon.Attributes;
using Shelldon.Utils;
namespace Shelldon.Descriptors.Parameters;
public class ArgumentParameterDescriptor : ParameterDescriptor, IPublicParameterDescriptor
{
public string Name { get; }
public string Description { get; }
public int Order { get; } = 0;
public ArgumentParameterDescriptor(ParameterInfo parameterInfo, int index) : base(parameterInfo, index)
{
FromArgumentAttribute argumentAttribute = parameterInfo.GetCustomAttribute<FromArgumentAttribute>()
?? throw new InvalidOperationException($"Parameter '{parameterInfo.Name}' must have an {nameof(FromArgumentAttribute)}.");
Description = argumentAttribute.Description;
Order = argumentAttribute.Order;
if (!string.IsNullOrEmpty(argumentAttribute.Name))
{
ValidationUtils.ValidateCliMember(argumentAttribute.Name);
Name = argumentAttribute.Name;
}
else if (parameterInfo.Name is not null)
Name = parameterInfo.Name.Trim('_').ToKebab();
else
throw new InvalidOperationException($"Parameter '{parameterInfo.Name}' must have a name or an {nameof(FromArgumentAttribute)} with a name.");
}
}
@@ -0,0 +1,20 @@
using System.Reflection;
namespace Shelldon.Descriptors;
public interface IPublicParameterDescriptor
{
/// <summary>
/// Gets the name of the parameter.
/// </summary>
string Name { get; }
/// <summary>
/// Gets the description of the parameter.
/// </summary>
string Description { get; }
public ParameterInfo ParameterInfo { get; }
public int Index { get; }
}
@@ -0,0 +1,41 @@
using System.Reflection;
using Shelldon.Attributes;
using Shelldon.Utils;
namespace Shelldon.Descriptors.Parameters;
public class OptionParameterDescriptor : ParameterDescriptor, IPublicParameterDescriptor
{
public string Name { get; }
public string Description { get; }
public string ShortNames { get; }
public bool IsBoolean { get; }
public OptionParameterDescriptor(ParameterInfo parameterInfo, int index) : base(parameterInfo, index)
{
FromOptionAttribute? optionAttribute = parameterInfo.GetCustomAttribute<FromOptionAttribute>();
ShortNames = optionAttribute?.ShortNames ?? string.Empty;
Description = optionAttribute?.Description ?? string.Empty;
IsBoolean = BaseType == typeof(bool) || BaseType == typeof(bool?);
if (!string.IsNullOrEmpty(optionAttribute?.ShortNames))
foreach (char shortName in optionAttribute.ShortNames)
if (!char.IsLetterOrDigit(shortName))
throw new ArgumentException($"Short names must be alphanumeric characters, but '{shortName}' is not.");
if (!string.IsNullOrEmpty(optionAttribute?.Name))
{
ValidationUtils.ValidateCliMember(optionAttribute.Name);
Name = optionAttribute.Name;
}
else if (parameterInfo.Name is not null)
Name = parameterInfo.Name.Trim('_').ToKebab();
else
throw new ArgumentException($"Parameter '{parameterInfo.Name}' does not have a valid name for an option.");
}
}
@@ -0,0 +1,49 @@
using System.Reflection;
using Shelldon.Attributes;
using Shelldon.Utils;
namespace Shelldon.Descriptors.Parameters;
public class ParameterDescriptor
{
public ParameterInfo ParameterInfo { get; }
public Type ParameterType => ParameterInfo.ParameterType;
public Type BaseType { get; }
public int Index { get; }
public bool IsCollection { get; } = false;
public bool IsNullable { get; } = false;
public bool IsOptional => ParameterInfo.IsOptional;
public bool IsHidden { get; }
public object? DefaultValue => ParameterInfo.DefaultValue is DBNull ? null : ParameterInfo.DefaultValue;
public ParameterDescriptor(ParameterInfo parameterInfo, int index)
{
ParameterInfo = parameterInfo;
Index = index;
Type type = parameterInfo.ParameterType;
if (type.IsNullable())
{
type = type.GetUnderlyingType();
IsNullable = true;
}
if (type.IsCollectionType())
{
IsCollection = true;
type = type.GetUnderlyingType();
}
BaseType = type;
IsHidden = parameterInfo.GetCustomAttribute<HiddenAttribute>() is not null;
}
}
@@ -0,0 +1,16 @@
namespace Shelldon.Descriptors.Parameters;
public class ParameterDescriptorCollection
{
public required IReadOnlyList<ServiceParameterDescriptor> Services { get; init; }
public required IReadOnlyList<OptionParameterDescriptor> Options { get; init; }
public required IReadOnlyList<ArgumentParameterDescriptor> Arguments { get; init; }
public required IReadOnlyDictionary<string, OptionParameterDescriptor> OptionsMap { get; init; }
public int Count => Services.Count + Options.Count + Arguments.Count;
public IReadOnlyList<IPublicParameterDescriptor> AllPublic => [.. Options, .. Arguments];
}
@@ -0,0 +1,9 @@
using System.Reflection;
using Shelldon.Attributes;
namespace Shelldon.Descriptors.Parameters;
public class ServiceParameterDescriptor(ParameterInfo parameterInfo, int index) : ParameterDescriptor(parameterInfo, index)
{
public object? Key { get; } = parameterInfo.GetCustomAttribute<FromServiceAttribute>()?.Key;
}
+3
View File
@@ -0,0 +1,3 @@
namespace Shelldon.Exceptions;
public class CliException(string? message) : Exception(message) { }
+3
View File
@@ -0,0 +1,3 @@
namespace Shelldon.Exceptions;
// public class CliParseException : CliException { }
+163
View File
@@ -0,0 +1,163 @@
using Shelldon.Attributes;
using Shelldon.Descriptors;
using Shelldon.Descriptors.Parameters;
using Shelldon.Utils;
namespace Shelldon.Help;
public class CommandHelp
{
public List<CommandHelpElement> Items { get; } = [];
public override string ToString() =>
string.Join("\n\n", Items.Select(item => string.Join('\n', item.GetLines(Console.WindowWidth))));
public static CommandHelp BuildHelpMessageForContext(CommandContext context)
{
CommandHelp help = new();
help.Items.Add(new CommandHelpUsage(context));
ICommandActionDescriptor? commandDescriptor = context.Command;
ICommandSectionDescriptor sectionDescriptor = context.ResolutionPath[^1];
bool useSection = commandDescriptor is null || commandDescriptor.Name == "@";
// Description
{
List<CommandHelpText> descriptions = [];
if (useSection && !string.IsNullOrEmpty(sectionDescriptor.Description))
descriptions.Add(new CommandHelpText(sectionDescriptor.Description));
if (!string.IsNullOrEmpty(commandDescriptor?.Description))
{
string @default = descriptions.Count > 0
? "(Default) ".ItalicConsole(Console.IsOutputRedirected)
: string.Empty;
descriptions.Add(new CommandHelpText(@default + commandDescriptor.Description));
}
if (descriptions.Count > 0)
help.Items.Add(new CommandHelpSection("Description", [.. descriptions]));
}
// Subcommands
if (useSection && sectionDescriptor.Commands.Count > 0)
{
Dictionary<string, string> commands = [];
foreach (ICommandDescriptor cmd in sectionDescriptor.Commands)
{
if (!cmd.IsHidden && cmd.Name != "@")
commands.Add(cmd.Name, cmd.Description);
}
if (commands.Count > 0)
help.Items.Add(new CommandHelpSection("Commands", new CommandHelpKeyValueList(commands)));
}
// Arguments and Options
if (commandDescriptor is not null)
{
if (commandDescriptor.ParameterDescriptors.Arguments.Count > 0)
{
Dictionary<string, string> arguments = [];
for (int i = 0; i < commandDescriptor.ParameterDescriptors.Arguments.Count; i++)
{
ArgumentParameterDescriptor arg = commandDescriptor.ParameterDescriptors.Arguments[i];
string typeName = arg.IsCollection ? $"{arg.BaseType.Name}[]" : arg.BaseType.Name;
string required = arg.IsOptional || arg.IsNullable ? "(Optional)" : "(Required)";
string description = string.IsNullOrEmpty(arg.Description) ? required : $"{arg.Description} {required}";
if (arg.IsHidden)
{
if (arg.IsOptional || arg.IsNullable || arg.IsCollection)
continue;
arguments.Add($"{i}: Hidden".ItalicConsole(Console.IsOutputRedirected), string.Empty);
}
else
arguments.Add($"{i}: {arg.Name} ({typeName})", description);
}
if (arguments.Count > 0)
help.Items.Add(new CommandHelpSection("Arguments", new CommandHelpKeyValueList(arguments)));
}
if (commandDescriptor.ParameterDescriptors.Options.Count > 0 || context.GlobalOptions.Count > 0)
{
Dictionary<string, string> options = [];
int maxShortNames = commandDescriptor.ParameterDescriptors.Options.Count > 0 ? commandDescriptor.ParameterDescriptors.Options.Max(i => i.ShortNames.Length) : 0;
maxShortNames = Math.Max(maxShortNames, context.GlobalOptions.Max(i => i.ShortNames.Length));
for (int i = 0; i < commandDescriptor.ParameterDescriptors.Options.Count; i++)
{
OptionParameterDescriptor opt = commandDescriptor.ParameterDescriptors.Options[i];
string names = new string(' ', (maxShortNames - opt.ShortNames.Length) * 4) +
string.Join(", ", opt.ShortNames.Select(sn => $"-{sn}, "));
names += $"--{opt.Name}";
string typeName = opt.IsCollection ? $"<{opt.BaseType.Name}[]>" : $"<{opt.BaseType.Name}>";
string required = opt.IsOptional || opt.IsNullable ? "(Optional)" : "(Required)";
required = opt.IsBoolean ? string.Empty : required;
string description = string.IsNullOrEmpty(opt.Description) ? required : $"{opt.Description} {required}";
if (opt.IsHidden)
{
if (opt.IsOptional || opt.IsNullable || opt.IsCollection || opt.IsBoolean)
continue;
options.Add($"{names}", "Hidden".ItalicConsole(Console.IsOutputRedirected));
}
else
options.Add($"{names}{(opt.IsBoolean ? string.Empty : " " + typeName)}", description);
}
foreach (IGlobalOptionDescriptor globalOption in context.GlobalOptions)
{
if (globalOption.IsHidden)
continue;
string names = new string(' ', (maxShortNames - globalOption.ShortNames.Length) * 4) +
string.Join(", ", globalOption.ShortNames.Select(sn => $"-{sn}, ")) +
$"--{globalOption.Name}";
options.Add(names, globalOption.Description);
}
if (options.Count > 0)
help.Items.Add(new CommandHelpSection("Options", new CommandHelpKeyValueList(options)));
}
}
// Examples
{
List<string> examples = [];
if (useSection)
{
foreach (Attribute attribute in sectionDescriptor.Metadata)
if (attribute is HelpExampleAttribute helpExample)
examples.Add(helpExample.Example);
}
foreach (Attribute attribute in commandDescriptor?.Metadata ?? [])
if (attribute is HelpExampleAttribute helpExample)
examples.Add(helpExample.Example);
if (examples.Count > 0)
help.Items.Add(new CommandHelpSection(
examples.Count > 1 ? "Examples" : "Example",
new CommandHelpPreFormattedText(string.Join('\n', examples)))
);
}
return help;
}
}
+6
View File
@@ -0,0 +1,6 @@
namespace Shelldon.Help;
public abstract class CommandHelpElement
{
public abstract string[] GetLines(int maxWidth);
}
+36
View File
@@ -0,0 +1,36 @@
using System.Text;
namespace Shelldon.Help;
public class CommandHelpKeyValueList(Dictionary<string, string> list) : CommandHelpElement
{
public Dictionary<string, string> List { get; } = list;
public override string[] GetLines(int maxWidth)
{
List<string> lines = [];
int maxKeyLength = List.Keys.Max(k => k.Length) + 4;
StringBuilder sb = new();
foreach (KeyValuePair<string, string> keyValue in List)
{
string firstLine = keyValue.Key.PadRight(maxKeyLength);
string[] valueLines = new CommandHelpText(keyValue.Value).GetLines(maxWidth - maxKeyLength);
if (valueLines.Length < 1)
{
lines.Add(firstLine);
continue;
}
firstLine += valueLines[0];
lines.Add(firstLine);
lines.AddRange(valueLines[1..].Select(line => new string(' ', maxKeyLength) + line));
}
return [.. lines];
}
}
@@ -0,0 +1,9 @@
namespace Shelldon.Help;
public class CommandHelpPreFormattedText(string text) : CommandHelpElement
{
public string Text { get; } = text;
public override string[] GetLines(int maxWidth) =>
Text.Split('\n');
}
+28
View File
@@ -0,0 +1,28 @@
using System.Text;
using Shelldon.Utils;
namespace Shelldon.Help;
public class CommandHelpSection(string header, params CommandHelpElement[] content) : CommandHelpElement
{
public string Header { get; } = header;
public List<CommandHelpElement> Content { get; } = [.. content];
public override string[] GetLines(int maxWidth)
{
List<string> lines = [];
lines.Add((Header + ":").BoldConsole(Console.IsOutputRedirected));
for (int i = 0; i < Content.Count; i++)
{
if (i > 0)
lines.Add(string.Empty);
lines.AddRange(Content[i].GetLines(maxWidth - 2).Select(line => new string(' ', 2) + line));
}
return [.. lines];
}
}
+48
View File
@@ -0,0 +1,48 @@
namespace Shelldon.Help;
public class CommandHelpText(string text) : CommandHelpElement
{
public string Text { get; } = text;
public override string[] GetLines(int maxWidth)
{
if (string.IsNullOrWhiteSpace(Text))
return [];
List<string> lines = [];
string[] paragraphs = Text.Split('\n');
foreach (var paragraph in paragraphs)
{
string[] words = paragraph.Split(' ', StringSplitOptions.None);
string currentLine = string.Empty;
foreach (var word in words)
{
if (currentLine.Length + word.Length + (currentLine.Length > 0 ? 1 : 0) <= maxWidth)
{
if (currentLine.Length > 0)
currentLine += " ";
currentLine += word;
}
else
{
if (currentLine.Length > 0)
lines.Add(currentLine);
currentLine = word;
}
}
if (currentLine.Length > 0)
lines.Add(currentLine);
// Preserve explicit newline
if (paragraph != paragraphs[^1])
lines.Add(""); // Represents the original '\n'
}
return [.. lines];
}
}
+103
View File
@@ -0,0 +1,103 @@
using System.Diagnostics;
using System.Reflection;
using System.Text;
using Shelldon.Descriptors;
using Shelldon.Utils;
namespace Shelldon.Help;
public class CommandHelpUsage(CommandContext context) : CommandHelpElement
{
private readonly string _commandName = context.CommandName;
private readonly ICommandActionDescriptor? _commandDescriptor = context.Command;
public override string[] GetLines(int maxWidth)
{
StringBuilder sb = new();
sb.Append("Usage:".BoldConsole(Console.IsOutputRedirected));
if (!context.ContinueAsShell)
{
ProcessModule? mainModule = Process.GetCurrentProcess().MainModule;
string fileName = mainModule is null
? (Assembly.GetExecutingAssembly().GetName().Name ?? string.Empty)
: Path.GetFileName(mainModule.FileName);
if (!string.IsNullOrEmpty(fileName))
sb.Append(' ').Append(fileName);
}
if (!string.IsNullOrEmpty(_commandName))
sb.Append(' ').Append(_commandName);
{
bool hasSubCommands = _commandDescriptor is null || _commandDescriptor.Name == "@";
if (hasSubCommands)
sb.Append(' ').Append(_commandDescriptor?.Name == "@" ? "[command?]" : "[command]");
}
List<string> usageItems = [];
if (_commandDescriptor is not null)
{
usageItems.AddRange(_commandDescriptor.ParameterDescriptors.Options
.Where(i => !i.IsHidden || !(i.IsBoolean || i.IsCollection || i.IsNullable || i.IsOptional))
.Select(i =>
{
if (i.IsHidden)
return $"[--{i.Name} <Hidden>]".ItalicConsole(Console.IsOutputRedirected);
return i.IsBoolean
? $"[--{i.Name}]"
: $"[--{i.Name} <{i.BaseType.Name}>]";
})
);
usageItems.AddRange(_commandDescriptor.ParameterDescriptors.Arguments
.Select((i, index) =>
{
if (i.IsHidden)
return $"<{index}>";
return i.IsCollection
? $"<...{i.Name}>"
: $"<{i.Name}>";
})
);
}
List<string> lines = new(usageItems.Count)
{
sb.ToString()
};
string line = string.Empty;
int padding = Console.IsOutputRedirected ? sb.Length : sb.Length - 9;
foreach (string item in usageItems)
{
if (line.Length < 1)
{
line += ' ' + item;
continue;
}
if (line.Length + item.Length + padding > maxWidth)
{
lines[^1] += line;
lines.Add(new string(' ', padding));
line = string.Empty;
}
line += ' ' + item;
}
if (line.Length > 0)
lines[^1] += line;
return [.. lines];
}
}
+68
View File
@@ -0,0 +1,68 @@
using Microsoft.Extensions.Logging;
using Shelldon.Utils;
namespace Shelldon.Logging;
public sealed class ConsoleLogger(string name) : ILogger
{
public static LogLevel MinimumLogLevel { get; set; } = LogLevel.None;
public IDisposable? BeginScope<TState>(TState state) where TState : notnull =>
default!;
public bool IsEnabled(LogLevel logLevel) =>
logLevel >= MinimumLogLevel;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel))
return;
ConsoleColor originalBackground = Console.BackgroundColor;
ConsoleColor originalForeground = Console.ForegroundColor;
(ConsoleColor? background, ConsoleColor? foreground) = GetColor(logLevel);
if (background.HasValue)
Console.BackgroundColor = background.Value;
if (foreground.HasValue)
Console.ForegroundColor = foreground.Value;
Console.Write(GetLogLevelString(logLevel));
Console.ResetColor();
Console.Write(": ");
Console.Write(formatter(state, exception));
Console.Write($" ({$"{name}[{eventId.Id}]".ItalicConsole(Console.IsOutputRedirected)})");
Console.BackgroundColor = originalBackground;
Console.ForegroundColor = originalForeground;
Console.WriteLine();
}
private (ConsoleColor? background, ConsoleColor? foreground) GetColor(LogLevel logLevel)
{
return logLevel switch
{
LogLevel.Trace => (null, ConsoleColor.Gray),
LogLevel.Debug => (null, ConsoleColor.DarkGray),
LogLevel.Information => (null, ConsoleColor.Green),
LogLevel.Warning => (ConsoleColor.Black, ConsoleColor.Yellow),
LogLevel.Error => (ConsoleColor.DarkRed, ConsoleColor.White),
LogLevel.Critical => (ConsoleColor.DarkRed, ConsoleColor.White),
_ => (null, null)
};
}
private static string GetLogLevelString(LogLevel logLevel) =>
logLevel switch
{
LogLevel.Trace => "[trce]",
LogLevel.Debug => "[dbug]",
LogLevel.Information => "[info]",
LogLevel.Warning => "[warn]",
LogLevel.Error => "[fail]",
LogLevel.Critical => "[crit]",
_ => throw new ArgumentOutOfRangeException(nameof(logLevel))
};
}
@@ -0,0 +1,12 @@
using Microsoft.Extensions.Logging;
namespace Shelldon.Logging;
public class ConsoleLoggerConfiguration
{
public LogLevel MinimumLogLevel { get; set; } = LogLevel.None;
public string OptionName { get; set; } = "verbose";
public string ShortName { get; set; } = "v";
}
@@ -0,0 +1,36 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Configuration;
using Shelldon.Builder;
namespace Shelldon.Logging;
public static class ConsoleLoggerExtensions
{
public static ILoggingBuilder AddConsoleLogger(this ILoggingBuilder builder)
{
builder.Services.AddTransient<ILoggerProvider, ConsoleLoggerProvider>();
LoggerProviderOptions.RegisterProviderOptions<ConsoleLoggerConfiguration, ConsoleLoggerProvider>(builder.Services);
return builder;
}
public static ICommandSectionBuilder UseConsoleLogger(this ICommandSectionBuilder builder)
{
builder.MapGlobalOption("verbose", "v", (context, next) =>
{
if (context.Args.Contains("--verbose") || context.Args.Contains("-v"))
ConsoleLogger.MinimumLogLevel = LogLevel.Information;
else if (context.Args.Contains("-vv"))
ConsoleLogger.MinimumLogLevel = LogLevel.Debug;
else if (context.Args.Contains("-vvv"))
ConsoleLogger.MinimumLogLevel = LogLevel.Trace;
next();
ConsoleLogger.MinimumLogLevel = LogLevel.None;
})
.WithDescription("Enable verbose logging. You can specify -v, -vv, or -vvv for different levels of verbosity.");
return builder;
}
}
+18
View File
@@ -0,0 +1,18 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
namespace Shelldon.Logging;
public class ConsoleLoggerProvider : ILoggerProvider
{
private readonly ConcurrentDictionary<string, ConsoleLogger> _loggers = [];
public ILogger CreateLogger(string categoryName) =>
_loggers.GetOrAdd(categoryName, name => new ConsoleLogger(name));
public void Dispose()
{
_loggers.Clear();
GC.SuppressFinalize(this);
}
}
@@ -0,0 +1,93 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Shelldon.Descriptors;
namespace Shelldon.Middleware;
public static class GlobalOptionsMiddleware
{
public static ShellApplication UseGlobalOptions(this ShellApplication app) =>
app.Use(async (context, next) =>
{
ILogger<ShellApplication> logger = app.Services.GetRequiredService<ILogger<ShellApplication>>();
logger.LogTrace("Avaliable global options for route '{Route}': {Options}",
string.Join('/', [.. context.ResolutionPath.Select(i => i.Name), context.Command?.Name ?? "[X]"]),
string.Join(", ", context.GlobalOptions.Select(i => $"--{i.Name} ({string.Join(", ", i.ShortNames.Select(sn => $"-{sn}"))})"))
);
List<IGlobalOptionDescriptor> foundOptions = [];
foreach (IGlobalOptionDescriptor globalOption in context.GlobalOptions)
{
// TODO: More intelligent lookup
// TODO: Value support
int index = Array.IndexOf(context.Args, $"--{globalOption.Name}");
if (index < 0)
{
foreach (char shortName in globalOption.ShortNames)
{
index = context.Args.ToList().FindIndex(i => i[0] == '-' && i[1..].All(c => c == shortName));
if (index >= 0)
break;
}
}
if (index >= 0)
{
context.ConsumeArgs(index, 1);
foundOptions.Add(globalOption);
}
}
if (foundOptions.Count > 0)
logger.LogDebug("Command: '{Command}' triggered global options: {Options}",
$"{(string.IsNullOrEmpty(context.CommandName) ? "@" : context.CommandName)} {string.Join(" ", context.Args)}",
string.Join(", ", foundOptions.Select(i => $"--{i.Name}"))
);
Func<Task> action = next;
for (int i = foundOptions.Count - 1; i >= 0; i--)
{
Delegate currentOption = foundOptions[i].Delegate;
Func<Task> previousNext = action;
action = CreateInvoker(currentOption, previousNext, context);
}
await action.Invoke();
});
private static Func<Task> CreateInvoker(Delegate current, Func<Task> next, CommandContext context)
{
// Check if the current middleware is async
bool isAsync = current.Method.ReturnType == typeof(Task);
Delegate resultNext = next;
if (!isAsync)
resultNext = () => next.Invoke().GetAwaiter().GetResult();
return current.Method.GetParameters().Length switch
{
1 => isAsync ?
() => (Task)current.DynamicInvoke(resultNext)! :
() =>
{
current.DynamicInvoke(resultNext);
return Task.CompletedTask;
}
,
2 => isAsync ?
() => (Task)current.DynamicInvoke(context, resultNext)! :
() =>
{
current.DynamicInvoke(context, resultNext);
return Task.CompletedTask;
}
,
_ => throw new InvalidOperationException($"Global option '{current.Method.Name}' has an invalid number of parameters. It should have 1 or 2 parameters.")
};
}
}
+49
View File
@@ -0,0 +1,49 @@
using Shelldon.Builder;
using Shelldon.Help;
namespace Shelldon.Middleware;
public static class HelpMiddleware
{
public static ShellApplication UseHelpBanner(this ShellApplication app) =>
UseHelpBanner(app, "See '--help' for usage.");
public static ShellApplication UseHelpBanner(this ShellApplication app, string banner) =>
app.Use((context, next) =>
{
next();
if (context.ExitCode != 0)
{
Console.WriteLine();
Console.WriteLine(banner);
}
});
public static ShellApplication MapHelpOption(this ShellApplication app) =>
MapHelpOption(app, "help", "h");
public static ShellApplication MapHelpOption(this ShellApplication app, string name, string shortNames, Action<IGlobalOptionBuilder> configure)
{
configure(app.MapOption(name, shortNames));
return app;
}
public static ShellApplication MapHelpOption(this ShellApplication app, string name) =>
MapHelpOption(app, name, string.Empty);
public static ShellApplication MapHelpOption(this ShellApplication app, string name, string shortNames)
{
app.MapOption(name, shortNames)
.WithDescription("Display help information for the command.");
return app;
}
private static IGlobalOptionBuilder MapOption(this ShellApplication app, string name, string shortNames) =>
app.MapGlobalOption(name, shortNames, (context, _) =>
{
CommandHelp help = CommandHelp.BuildHelpMessageForContext(context);
Console.WriteLine(help);
});
}
+37
View File
@@ -0,0 +1,37 @@
using System.Reflection;
using Shelldon.Builder;
namespace Shelldon.Middleware;
public static class VersionMiddleware
{
public static ShellApplication MapVersionOption(this ShellApplication app) =>
MapVersionOption(app, "version", "v");
public static ShellApplication MapVersionOption(this ShellApplication app, string name, string shortNames, Action<IGlobalOptionBuilder> configure)
{
configure(app.MapOption(name, shortNames));
return app;
}
public static ShellApplication MapVersionOption(this ShellApplication app, string name) =>
MapVersionOption(app, name, string.Empty);
public static ShellApplication MapVersionOption(this ShellApplication app, string name, string shortNames)
{
app.MapOption(name, shortNames)
.WithDescription("Display the version of the application.")
.Scoped();
return app;
}
private static IGlobalOptionBuilder MapOption(this ShellApplication app, string name, string shortNames) =>
app.MapGlobalOption(name, shortNames, _ =>
{
string version = Assembly.GetExecutingAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? "unknown";
Console.WriteLine(version);
});
}
+51
View File
@@ -0,0 +1,51 @@
using Shelldon.Descriptors;
namespace Shelldon.Services;
public class CommandTreeProvider
{
public CommandContext LastResolvedContext { get; private set; } = new(null, string.Empty, [], [], []);
public ICommandSectionDescriptor RootSection { get; set; } = null!;
public CommandContext ResolveCommand(string[] arguments)
{
CommandContext resolvedContext = FindCommand(RootSection, arguments, string.Empty, [], []);
LastResolvedContext = resolvedContext;
return resolvedContext;
}
private static CommandContext FindCommand(
ICommandSectionDescriptor root,
string[] arguments,
string path,
ICommandSectionDescriptor[] resolveChain,
IGlobalOptionDescriptor[] globalOptions
)
{
if (arguments.Length > 0)
{
string name = arguments[0].ToLowerInvariant();
if (root.Routes.TryGetValue(name, out ICommandDescriptor? descriptor))
{
if (descriptor is ICommandActionDescriptor commandDescriptor)
return new(commandDescriptor, $"{path} {name}".TrimStart(), arguments[1..], [.. resolveChain, root], [.. globalOptions, .. root.GlobalOptions]);
else if (descriptor is ICommandSectionDescriptor sectionDescriptor)
return FindCommand(sectionDescriptor, arguments[1..], $"{path} {name}".TrimStart(), [.. resolveChain, root], [.. globalOptions, .. root.GlobalOptions.Where(i => !i.IsScoped)]);
else
throw new InvalidOperationException($"Unexpected command descriptor type: {descriptor.GetType().Name}");
}
}
if (root.Routes.TryGetValue("@", out ICommandDescriptor? defaultDescriptor))
{
if (defaultDescriptor is ICommandActionDescriptor commandDescriptor)
return new(commandDescriptor, path, arguments, [.. resolveChain, root], [.. globalOptions, .. root.GlobalOptions]);
else
throw new InvalidOperationException($"Unexpected command descriptor type: {defaultDescriptor.GetType().Name}");
}
else
return new(null, path, arguments, [.. resolveChain, root], [.. globalOptions, .. root.GlobalOptions]);
}
}
+88
View File
@@ -0,0 +1,88 @@
using System.Runtime.InteropServices;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Shelldon.Exceptions;
using Shelldon.Utils;
namespace Shelldon.Services;
public class ExecutionService(
string[] initialArgs,
IHostApplicationLifetime appLifetime,
CommandTreeProvider commandTreeProvider,
IServiceProvider serviceProvider,
MiddlewareService middlewareService
) : IHostedService
{
private readonly string[] _initalArgs = initialArgs;
private readonly IHostApplicationLifetime _lifetime = appLifetime;
private readonly CommandTreeProvider _commandTreeProvider = commandTreeProvider;
private readonly IServiceProvider _serviceProvider = serviceProvider;
private readonly MiddlewareService _middlewareService = middlewareService;
private readonly CancellationTokenSource _cancellationTokenSource = new();
private Task? _hostedTask = null;
public Task StartAsync(CancellationToken cancellationToken)
{
_hostedTask = Task.Run(RunApplicationAsync, _cancellationTokenSource.Token);
return Task.CompletedTask;
}
private async void RunApplicationAsync()
{
// Wait for the application to be fully started
TaskCompletionSource<bool> startTcs = new();
_lifetime.ApplicationStarted.Register(() => startTcs.TrySetResult(true));
await startTcs.Task.ConfigureAwait(false);
CommandContext commandContext = _commandTreeProvider.ResolveCommand(_initalArgs);
if (commandContext.IsResolved)
{
async Task terminalAction()
{
try
{
using IServiceScope scope = _serviceProvider.CreateScope();
object?[] parameters = ArgumentUtils.BindArguments(commandContext, scope.ServiceProvider, _cancellationTokenSource.Token);
object? result = await TypeUtils.CallDelegateAsync(commandContext.Command.Delegate, parameters);
if (commandContext.Command.ReturnsExitCode && result is not null)
commandContext.ExitCode = (int)result;
}
catch (CliException ex)
{
Console.Error.WriteLine($"Error: {ex.Message}".FormatConsole(Console.IsOutputRedirected, ConsoleForegroundColor.Red));
commandContext.ExitCode = 1;
}
}
await _middlewareService.RunMiddleware(terminalAction, commandContext);
}
else
{
await _middlewareService.RunMiddleware(() =>
{
Console.Error.WriteLine($"Error: Command '{commandContext.CommandName}' not found".FormatConsole(Console.IsErrorRedirected, ConsoleForegroundColor.Red));
commandContext.ExitCode = 1;
return Task.CompletedTask;
}, commandContext);
}
Environment.ExitCode = commandContext.ExitCode;
_lifetime.StopApplication();
}
public Task StopAsync(CancellationToken cancellationToken)
{
if (!_cancellationTokenSource.IsCancellationRequested)
_cancellationTokenSource.Cancel();
if (_hostedTask is not null && !_hostedTask.IsCompleted)
Task.WaitAny([_hostedTask], cancellationToken);
return Task.CompletedTask;
}
}
+54
View File
@@ -0,0 +1,54 @@
namespace Shelldon.Services;
public class MiddlewareService
{
public IReadOnlyList<Delegate> Middleware { get; internal set; } = null!;
public async Task RunMiddleware(Func<Task> terminalAction, CommandContext context)
{
if (Middleware.Count < 1)
return;
Func<Task> next = terminalAction;
for (int i = Middleware.Count - 1; i >= 0; i--)
{
Delegate currentMiddleware = Middleware[i];
Func<Task> previousNext = next;
next = CreateInvoker(currentMiddleware, previousNext, context);
}
await next.Invoke();
}
private static Func<Task> CreateInvoker(Delegate current, Func<Task> next, CommandContext context)
{
// Check if the current middleware is async
bool isAsync = current.Method.ReturnType == typeof(Task);
Delegate resultNext = next;
if (!isAsync)
resultNext = () => next.Invoke().GetAwaiter().GetResult();
return current.Method.GetParameters().Length switch
{
1 => isAsync ?
() => (Task)current.DynamicInvoke(resultNext)! :
() =>
{
current.DynamicInvoke(resultNext);
return Task.CompletedTask;
},
2 => isAsync ?
() => (Task)current.DynamicInvoke(context, resultNext)! :
() =>
{
current.DynamicInvoke(context, resultNext);
return Task.CompletedTask;
}
,
_ => throw new InvalidOperationException($"Middleware '{current.Method.Name}' has an invalid number of parameters. It should have 1 or 2 parameters.")
};
}
}
+56
View File
@@ -0,0 +1,56 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Shelldon.Builder;
using Shelldon.Descriptors;
using Shelldon.Services;
namespace Shelldon;
public class ShellApplication : IHost, ICommandSectionBuilder
{
private readonly IHost _host;
private readonly CommandSectionBuilder _rootCommandBuilder = new("@");
public List<Delegate> Middleware { get; } = [];
// IHost implementation
public IServiceProvider Services => _host.Services;
// ICommandSectionBuilder implementation
public List<ICommandBuilder> Commands => _rootCommandBuilder.Commands;
public List<Attribute> Metadata => _rootCommandBuilder.Metadata;
public List<IGlobalOptionBuilder> GlobalOptions => _rootCommandBuilder.GlobalOptions;
public void Dispose()
{
_host.Dispose();
GC.SuppressFinalize(this);
}
public Task StartAsync(CancellationToken cancellationToken = default)
{
Services.GetRequiredService<CommandTreeProvider>().RootSection = _rootCommandBuilder.Build(isRoot: true);
Services.GetRequiredService<MiddlewareService>().Middleware = Middleware.AsReadOnly();
return _host.StartAsync(cancellationToken);
}
public Task StopAsync(CancellationToken cancellationToken = default) =>
_host.StopAsync(cancellationToken);
internal ShellApplication(IHost host) =>
_host = host ?? throw new ArgumentNullException(nameof(host));
public static ShellApplicationBuilder CreateBuilder() =>
new(new());
public static ShellApplicationBuilder CreateBuilder(string[] args) =>
new(new() { Args = args });
public static ShellApplicationBuilder CreateBuilder(HostApplicationBuilderSettings settings) =>
new(settings);
ICommandDescriptor ICommandBuilder.Build() =>
_rootCommandBuilder.Build();
}
+66
View File
@@ -0,0 +1,66 @@
using System.Runtime.InteropServices;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.Metrics;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Configuration;
using Shelldon.Logging;
using Shelldon.Services;
using Shelldon.Utils;
namespace Shelldon;
/// <summary>
/// Represents a hosted applications and services builder that helps manage configuration, logging, lifetime, and more.
/// </summary>
public sealed class ShellApplicationBuilder : IHostApplicationBuilder
{
private readonly HostApplicationBuilder _builder;
public IConfigurationManager Configuration => _builder.Configuration;
public IHostEnvironment Environment => _builder.Environment;
public ILoggingBuilder Logging => _builder.Logging;
public IMetricsBuilder Metrics => _builder.Metrics;
public IServiceCollection Services => _builder.Services;
IDictionary<object, object> IHostApplicationBuilder.Properties => ((IHostApplicationBuilder)_builder).Properties;
internal ShellApplicationBuilder(HostApplicationBuilderSettings settings)
{
_builder = Host.CreateApplicationBuilder(settings);
_builder.Logging
.ClearProviders()
.AddConsoleLogger()
// .AddConsole()
.AddEventSourceLogger()
.AddDebug();
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
_builder.Logging.AddEventLog();
_builder.Services.AddSingleton<CommandTreeProvider>();
_builder.Services.AddSingleton<MiddlewareService>();
_builder.Services.AddScoped(sp => sp.GetRequiredService<CommandTreeProvider>().LastResolvedContext);
_builder.Services.AddHostedService<ExecutionService>(
sp => new(
settings.Args ?? [],
sp.GetRequiredService<IHostApplicationLifetime>(),
sp.GetRequiredService<CommandTreeProvider>(),
sp,
sp.GetRequiredService<MiddlewareService>()
)
);
}
public void ConfigureContainer<TContainerBuilder>(IServiceProviderFactory<TContainerBuilder> factory, Action<TContainerBuilder>? configure = null) where TContainerBuilder : notnull =>
_builder.ConfigureContainer(factory, configure);
public ShellApplication Build() =>
new(_builder.Build());
}
+15
View File
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.6" />
</ItemGroup>
</Project>
+408
View File
@@ -0,0 +1,408 @@
using System.Collections.ObjectModel;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Reflection;
using System.Runtime.Versioning;
using Shelldon.Descriptors;
using Shelldon.Descriptors.Parameters;
using Shelldon.Exceptions;
namespace Shelldon.Utils;
public static class ArgumentUtils
{
public static object?[] BindArguments(CommandContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken)
{
ParameterDescriptorCollection parameters = context.Command!.ParameterDescriptors;
string[] args = context.Args;
object?[] paramValues = new object?[parameters.Count];
foreach (ServiceParameterDescriptor serviceParameter in parameters.Services)
{
if (serviceParameter.ParameterType == typeof(CancellationToken))
{
paramValues[serviceParameter.Index] = cancellationToken;
continue;
}
object? service = serviceProvider.GetService(serviceParameter.ParameterType);
if (TryBindArgument(serviceParameter, service, out service))
paramValues[serviceParameter.Index] = service;
else
throw new CliException($"Service '{serviceParameter.ParameterType.Name}' for parameter '{serviceParameter.ParameterInfo.Name}' does not exist.");
}
Dictionary<OptionParameterDescriptor, string> keyValuePairs = [];
List<string> arguments = [];
for (int i = 0; i < args.Length; i++)
{
string arg = args[i];
if (arg[0] != '-' || arg == "-")
{
if (context.ConsumedArgs.TryGetValue(i, out int skipCount))
i += skipCount - 1;
else
arguments.Add(arg);
continue;
}
if (arg == "--")
{
if (i < args.Length - 1)
arguments.AddRange(args[(i + 1)..]);
break;
}
string[] keyValue = arg.Split('=', 2);
if (arg[1] == '-')
{
// Long option format: --option=value
string optionName = keyValue[0];
if (!parameters.OptionsMap.TryGetValue(optionName, out OptionParameterDescriptor? optionDescriptor))
{
if (context.ConsumedArgs.TryGetValue(i, out int skipCount))
{
i += skipCount - 1;
continue;
}
throw new CliException($"Unknown option '{optionName}'.");
}
if (keyValuePairs.ContainsKey(optionDescriptor) && !optionDescriptor.IsCollection)
throw new CliException($"Duplicate value of option '{optionName}'.");
string value = string.Empty;
if (keyValue.Length == 2)
value = keyValue[1];
else if (i < args.Length - 1 && IsValidCandidate(args[i + 1], optionDescriptor.IsBoolean))
value = args[++i];
else if (optionDescriptor.IsBoolean)
value = "true";
if (keyValuePairs.ContainsKey(optionDescriptor))
keyValuePairs[optionDescriptor] += '\0' + value;
else
keyValuePairs[optionDescriptor] = value;
}
else
{
string flags = keyValue[0].TrimStart('-');
for (int k = 0; k < flags.Length; k++)
{
string flag = "-" + flags[k];
if (!parameters.OptionsMap.TryGetValue(flag, out OptionParameterDescriptor? optionDescriptor))
{
if (context.ConsumedArgs.TryGetValue(i, out int skipCount))
{
i += skipCount - 1;
continue;
}
throw new CliException($"Unknown option '{flag}'.");
}
if (keyValuePairs.ContainsKey(optionDescriptor) && !optionDescriptor.IsCollection)
throw new CliException($"Duplicate value for option '--{optionDescriptor.Name}'.");
string value = string.Empty;
if (k < flags.Length - 1)
value = "true";
else
{
if (keyValue.Length == 2)
value = keyValue[1];
else if (args.Length > i + 1 && IsValidCandidate(args[i + 1], optionDescriptor.IsBoolean))
value = args[++i];
else if (optionDescriptor.IsBoolean)
value = "true";
}
if (keyValuePairs.ContainsKey(optionDescriptor))
keyValuePairs[optionDescriptor] += '\0' + value;
else
keyValuePairs[optionDescriptor] = value;
}
}
}
foreach (OptionParameterDescriptor optionDescriptor in parameters.Options)
{
object? value = null;
if (keyValuePairs.TryGetValue(optionDescriptor, out string? rawValue) && !string.IsNullOrEmpty(rawValue) && !TryConvertArgument(rawValue, optionDescriptor, out value))
throw new CliException($"Cannot convert value '{rawValue}' for option '--{optionDescriptor.Name}' to type '{optionDescriptor.BaseType.Name}'.");
else if (TryBindArgument(optionDescriptor, value, out value))
paramValues[optionDescriptor.Index] = value;
else if (value is null && optionDescriptor.IsBoolean)
paramValues[optionDescriptor.Index] = false;
else
throw new CliException($"Value for option '--{optionDescriptor.Name}' is required.");
}
int arrayAtIndex = -1;
for (int i = 0; i < parameters.Arguments.Count; i++)
{
ArgumentParameterDescriptor argumentDescriptor = parameters.Arguments[i];
if (argumentDescriptor.IsCollection)
{
arrayAtIndex = i;
continue;
}
string? argument = null;
int index = 0;
if (arrayAtIndex < 0)
argument = arguments.FirstOrDefault();
else
{
index = arguments.Count - parameters.Arguments.Count + i;
if (index > 0 && index < arguments.Count)
argument = arguments[index];
}
object? value = null;
if (argument is not null && !TryConvertArgument(argument, argumentDescriptor, out value))
throw new CliException($"Cannot convert value '{argument}' for argument '{argumentDescriptor.Name}' to type '{argumentDescriptor.BaseType.Name}'");
else if (TryBindArgument(argumentDescriptor, value, out _))
paramValues[argumentDescriptor.Index] = value;
else
throw new CliException($"Value for argument '{argumentDescriptor.Name}' is required.");
if (arrayAtIndex < 0)
arguments.RemoveAt(0);
else
arguments.RemoveAt(index);
}
if (arrayAtIndex >= 0)
{
ArgumentParameterDescriptor argumentArrayDescriptor = parameters.Arguments[arrayAtIndex];
if (TryConvertArgument(string.Join('\0', arguments), argumentArrayDescriptor, out object? value))
paramValues[argumentArrayDescriptor.Index] = value;
else
throw new CliException($"Cannot convert values for argument '{argumentArrayDescriptor.Name}' to type '{argumentArrayDescriptor.ParameterType.Name}'");
}
else if (arguments.Count > 0)
throw new CliException($"Too many arguments provided. Expected {parameters.Arguments.Count}, but got {arguments.Count + parameters.Arguments.Count}.");
IReadOnlyList<IPublicParameterDescriptor> publicParameters = parameters.AllPublic;
foreach (IPublicParameterDescriptor parameter in publicParameters)
{
ValidationAttribute[] validationAttributes = [.. parameter.ParameterInfo.GetCustomAttributes<ValidationAttribute>()];
if (validationAttributes.Length < 1)
continue;
foreach (ValidationAttribute validationAttribute in validationAttributes)
{
if (!validationAttribute.IsValid(paramValues[parameter.Index]))
throw new CliException($"({parameter.Name}) {validationAttribute.FormatErrorMessage(parameter.Name)}");
}
}
return paramValues;
}
public static object? BindArgument(ParameterDescriptor parameter, object? value)
{
if (value is not null)
return value;
if (parameter.IsOptional && parameter.DefaultValue is not null)
return parameter.DefaultValue;
if (parameter.IsCollection)
return ConvertToArray(parameter.ParameterType, []);
if (parameter.IsNullable)
return null;
throw new ArgumentNullException($"Parameter '{parameter.ParameterInfo.Name}' is required but not provided.");
}
public static bool TryBindArgument(ParameterDescriptor parameter, object? value, out object? bindedValue)
{
if (value is not null)
{
bindedValue = value;
return true;
}
if (parameter.IsOptional && parameter.DefaultValue is not null)
{
bindedValue = parameter.DefaultValue;
return true;
}
if (parameter.IsCollection)
{
bindedValue = ConvertToArray(parameter.ParameterType, []);
return true;
}
if (parameter.IsNullable)
{
bindedValue = null;
return true;
}
bindedValue = null;
return false;
}
private static bool IsValidCandidate(string value, bool isBoolean)
{
if (isBoolean)
return StringUtils.TryParseBoolean(value, out _);
return value[0] != '-' || value == "-";
}
private static bool TryConvertArgument(string argument, ParameterDescriptor descriptor, out object? value)
{
try
{
if (descriptor.IsCollection)
{
if (string.IsNullOrEmpty(argument))
{
value = ConvertToArray(descriptor.ParameterType, []);
return true;
}
string[] items = argument.Split('\0');
Type itemType = descriptor.BaseType;
object?[] convertedItems = new object?[items.Length];
for (int i = 0; i < items.Length; i++)
convertedItems[i] = ConvertToType(items[i], itemType);
value = ConvertToArray(descriptor.ParameterType, convertedItems);
}
else
value = ConvertToType(argument, descriptor.ParameterType);
return true;
}
catch
{
value = null;
return false;
}
}
private static object? ConvertToType(string value, Type targetType)
{
Type type = targetType.IsNullable() ? targetType.GetUnderlyingType() : targetType;
if (type == typeof(string))
return value;
if (string.IsNullOrEmpty(value))
{
if (targetType.IsNullable())
return null;
throw new ArgumentNullException($"Value for type '{targetType.FullName}' is required but not provided.");
}
if (type == typeof(bool))
{
if (StringUtils.TryParseBoolean(value, out bool? boolValue))
return boolValue;
throw new InvalidCastException($"Cannot convert '{value}' to type '{type.FullName}'.");
}
if (type.IsPrimitive)
return Convert.ChangeType(value, type);
if (type.IsEnum)
return Enum.Parse(type, value, ignoreCase: true);
if (type == typeof(decimal))
return decimal.Parse(value);
if (type == typeof(Guid))
return Guid.Parse(value);
if (type == typeof(DateTime))
return DateTime.Parse(value, CultureInfo.CurrentUICulture);
if (type == typeof(DateTimeOffset))
return DateTimeOffset.Parse(value, CultureInfo.InvariantCulture);
if (type == typeof(TimeSpan))
return TimeSpan.Parse(value, CultureInfo.InvariantCulture);
throw new InvalidCastException($"Cannot convert '{value}' to type '{type.FullName}'.");
}
private static object? ConvertToArray(Type targetType, object?[] items)
{
Type arrayType = targetType.IsNullable() ?
targetType.GetUnderlyingType() : targetType;
Type itemType = arrayType.IsArray ? arrayType.GetElementType()! : arrayType.GetGenericArguments()[0];
if (arrayType.IsArray)
{
Array result = Array.CreateInstance(itemType, items.Length);
for (int i = 0; i < items.Length; i++)
result.SetValue(items[i], i);
return result;
}
Type genericType = arrayType.GetGenericTypeDefinition();
object? casted = typeof(Enumerable)
.GetMethod(nameof(Enumerable.Cast))!
.MakeGenericMethod(itemType)
.Invoke(null, [items]);
if (genericType == typeof(IEnumerable<>))
return casted;
casted = typeof(Enumerable)
.GetMethod(nameof(Enumerable.ToList))!
.MakeGenericMethod(itemType)
.Invoke(null, [casted]);
if (genericType == typeof(List<>) || genericType == typeof(IList<>))
return casted;
if (genericType == typeof(Collection<>) || genericType == typeof(ICollection<>))
return Activator.CreateInstance(arrayType, [casted]);
if (genericType == typeof(IReadOnlyList<>) || genericType == typeof(IReadOnlyCollection<>))
return typeof(CollectionExtensions)
.GetMethod(nameof(CollectionExtensions.AsReadOnly))!
.MakeGenericMethod(itemType)
.Invoke(null, [casted]);
throw new NotSupportedException($"Target type '{genericType.Name}' is not supported.");
}
}
+7
View File
@@ -0,0 +1,7 @@
namespace Shelldon.Utils;
public static class AttributeUtils
{
public static T? GetAttribute<T>(this List<Attribute> attributes) where T : Attribute =>
attributes.FindLast(i => i is T) as T;
}
+106
View File
@@ -0,0 +1,106 @@
namespace Shelldon.Utils;
public static class ConsoleUtils
{
public static string FormatConsole(this string message, bool isRedirected, ConsoleForegroundColor color) =>
isRedirected ? message : $"\e[{(int)color}m{message}\e[{(int)ConsoleForegroundColor.Reset}m";
public static string BoldConsole(this string message, bool isRedirected) =>
isRedirected ? message : $"\e[1m{message}\e[22m";
public static string ItalicConsole(this string message, bool isRedirected) =>
isRedirected ? message : $"\e[3m{message}\e[23m";
public static void WriteTable(Dictionary<string, string> table)
{
if (table == null || table.Count == 0)
return;
int maxKeyLength = table.Keys.Max(k => k.Length) + 6;
int maxValueLength = Console.WindowWidth - maxKeyLength - 10;
foreach (KeyValuePair<string, string> entry in table)
{
string key = $" {entry.Key}".PadRight(maxKeyLength);
string value = entry.Value;
if (value.Length > maxValueLength)
{
string[] wrappedLines = WrapText(value, maxValueLength);
foreach (var line in wrappedLines)
{
Console.WriteLine($"{key}{line}");
key = new string(' ', maxKeyLength); // Reset key for subsequent lines
}
}
else
Console.WriteLine($"{key}{value}");
}
}
private static string[] WrapText(string text, int maxLineLength)
{
if (string.IsNullOrEmpty(text))
return [];
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxLineLength);
List<string> lines = [];
string[] paragraphs = text.Split('\n');
foreach (var paragraph in paragraphs)
{
string[] words = paragraph.Split(' ', StringSplitOptions.None);
string currentLine = string.Empty;
foreach (var word in words)
{
if (currentLine.Length + word.Length + (currentLine.Length > 0 ? 1 : 0) <= maxLineLength)
{
if (currentLine.Length > 0)
currentLine += " ";
currentLine += word;
}
else
{
if (currentLine.Length > 0)
lines.Add(currentLine);
currentLine = word;
}
}
if (currentLine.Length > 0)
lines.Add(currentLine);
// Preserve explicit newline
if (paragraph != paragraphs[^1])
lines.Add(""); // Represents the original '\n'
}
return [.. lines];
}
}
public enum ConsoleForegroundColor : int
{
Black = 30,
Red = 31,
Green = 32,
Yellow = 33,
Blue = 34,
Magenta = 35,
Cyan = 36,
White = 37,
BrightBlack = 90,
BrightRed = 91,
BrightGreen = 92,
BrightYellow = 93,
BrightBlue = 94,
BrightMagenta = 95,
BrightCyan = 96,
BrightWhite = 97,
Reset = 39
}
+8
View File
@@ -0,0 +1,8 @@
using System.Runtime.CompilerServices;
namespace Shelldon.Utils;
public interface ITask
{
public TaskAwaiter GetAwaiter();
}
+48
View File
@@ -0,0 +1,48 @@
using System.Diagnostics.CodeAnalysis;
using System.Text;
namespace Shelldon.Utils;
public static class StringUtils
{
public static string ToKebab(this string sourceString)
{
if (string.IsNullOrEmpty(sourceString))
return sourceString;
sourceString = sourceString.Trim();
StringBuilder stringBuilder = new();
for (int i = 0; i < sourceString.Length; i++)
{
char c = sourceString[i];
if (char.IsUpper(c))
{
if (i > 0 && (char.IsLower(sourceString[i - 1]) || (i < sourceString.Length - 1 && char.IsLower(sourceString[i + 1]))))
stringBuilder.Append('-');
stringBuilder.Append(char.ToLowerInvariant(c));
}
else if (char.IsWhiteSpace(c))
stringBuilder.Append('-');
else
stringBuilder.Append(c);
}
return stringBuilder.ToString();
}
internal static bool TryParseBoolean(string value, [NotNullWhen(true)] out bool? result)
{
result = value.ToLowerInvariant() switch
{
"true" or "yes" or "on" or "1" => true,
"false" or "no" or "off" or "0" => false,
_ => null
};
return result is not null;
}
}
+144
View File
@@ -0,0 +1,144 @@
using System.Collections.ObjectModel;
using System.Reflection;
namespace Shelldon.Utils;
internal static class TypeUtils
{
internal static bool IsAsync(this Delegate @delegate)
{
if (@delegate.Method.ReturnType == typeof(Task))
return true;
if (!@delegate.Method.ReturnType.IsGenericType)
return false;
return
@delegate.Method.ReturnType.GetGenericTypeDefinition() == typeof(ValueTask) ||
@delegate.Method.ReturnType.GetGenericTypeDefinition() == typeof(Task<>) ||
@delegate.Method.ReturnType.GetGenericTypeDefinition() == typeof(ValueTask<>);
}
internal static async Task<object?> CallDelegateAsync(Delegate @delegate, params object?[] parameters)
{
// Invoke the delegate
object? result = @delegate.DynamicInvoke(parameters);
if (result == null)
return null;
Type type = result.GetType();
// Handle Task
if (result is Task task)
{
await task.ConfigureAwait(false);
// If Task<T>, get Result
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Task<>))
{
return type.GetProperty("Result")!.GetValue(result);
}
return null; // plain Task has no result
}
// Handle ValueTask
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ValueTask<>))
{
// Convert to Task<T> and await
MethodInfo asTaskMethod = type.GetMethod("AsTask", BindingFlags.Public | BindingFlags.Instance)!;
Task taskResult = (Task)asTaskMethod.Invoke(result, null)!;
await taskResult.ConfigureAwait(false);
return type.GetProperty("Result")!.GetValue(result);
}
else if (type == typeof(ValueTask))
{
// Convert ValueTask to Task and await
var vt = (ValueTask)result;
await vt.ConfigureAwait(false);
return null;
}
// Handle synchronous (value-returning or void)
return result;
}
internal static Type GetUnderlyingType(this Type type)
{
if (type.IsArray)
return type.GetElementType()!;
if (!type.IsGenericType)
return type;
Type[] args = type.GetGenericArguments();
return args[0];
}
internal static bool IsNullable(this Type type) =>
type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>);
internal static bool IsCollectionType(this Type type)
{
if (type.IsNullable())
return IsCollectionType(type.GetUnderlyingType());
if (type.IsArray)
return true;
if (type.IsGenericType)
{
Type genericType = type.GetGenericTypeDefinition();
return
genericType == typeof(List<>) || genericType == typeof(IList<>) || genericType == typeof(IReadOnlyList<>) ||
genericType == typeof(Collection<>) || genericType == typeof(ICollection<>) || genericType == typeof(IReadOnlyCollection<>) ||
genericType == typeof(IEnumerable<>);
}
return false;
}
internal static bool IsConvertibleType(Type type)
{
if (type.IsNullable())
return IsConvertibleType(type.GetUnderlyingType());
if (type.IsPrimitive)
return true;
if (type.IsEnum)
return true;
if (type == typeof(string))
return true;
if (type == typeof(decimal))
return true;
if (type == typeof(DateTime))
return true;
if (type == typeof(DateTimeOffset))
return true;
if (type == typeof(Guid))
return true;
if (type == typeof(TimeSpan))
return true;
if (type.IsCollectionType())
{
Type itemType = type.IsArray ?
type.GetElementType()! :
type.GetGenericArguments()[0];
return IsConvertibleType(itemType);
}
return false;
}
}
+21
View File
@@ -0,0 +1,21 @@
namespace Shelldon.Utils;
public static class ValidationUtils
{
public static void ValidateCliMember(string name)
{
if (!char.IsLetterOrDigit(name[0]) || !char.IsLetterOrDigit(name[^1]))
throw new ArgumentException($"CLI member name '{name}' must start and end with a letter or digit.", nameof(name));
foreach (char c in name)
if (!char.IsLetterOrDigit(c) && c != '-' && c != '_')
throw new ArgumentException($"CLI member name '{name}' can only contain letters, digits, dashes, and underscores.", nameof(name));
}
public static void ValidateCliCommand(string name)
{
foreach (char c in name)
if (!char.IsLetterOrDigit(c) && c != '-' && c != '_')
throw new ArgumentException($"CLI member name '{name}' can only contain letters, digits, dashes, and underscores.", nameof(name));
}
}