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