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:
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -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.
|
||||
@@ -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})";
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Trace"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace Shelldon.Attributes;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Delegate | AttributeTargets.Method | AttributeTargets.Parameter, AllowMultiple = true, Inherited = false)]
|
||||
public class HiddenAttribute : Attribute { }
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Shelldon.Attributes;
|
||||
|
||||
public class ScopedAttribute : Attribute { }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using Shelldon.Descriptors;
|
||||
|
||||
namespace Shelldon.Builder;
|
||||
|
||||
public interface IGlobalOptionBuilder : IMetadataBuilder
|
||||
{
|
||||
public Delegate Delegate { get; }
|
||||
|
||||
public IGlobalOptionDescriptor Build();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Shelldon.Builder;
|
||||
|
||||
public interface IMetadataBuilder
|
||||
{
|
||||
public List<Attribute> Metadata { get; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Shelldon.Exceptions;
|
||||
|
||||
public class CliException(string? message) : Exception(message) { }
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Shelldon.Exceptions;
|
||||
|
||||
// public class CliParseException : CliException { }
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Shelldon.Help;
|
||||
|
||||
public abstract class CommandHelpElement
|
||||
{
|
||||
public abstract string[] GetLines(int maxWidth);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace Shelldon.Utils;
|
||||
|
||||
public interface ITask
|
||||
{
|
||||
public TaskAwaiter GetAwaiter();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user