diff --git a/src/SeqCli/Cli/Commands/QueryCommand.cs b/src/SeqCli/Cli/Commands/QueryCommand.cs index e36ee1a4..620efc04 100644 --- a/src/SeqCli/Cli/Commands/QueryCommand.cs +++ b/src/SeqCli/Cli/Commands/QueryCommand.cs @@ -18,7 +18,9 @@ using Seq.Api; using Seq.Api.Model.Signals; using SeqCli.Cli.Features; +using SeqCli.Config; using SeqCli.Connection; +using SeqCli.Csv; using Serilog; namespace SeqCli.Cli.Commands @@ -33,14 +35,17 @@ class QueryCommand : Command readonly SignalExpressionFeature _signal; string _query; int? _timeoutMS; + bool _noColor; - public QueryCommand(SeqConnectionFactory connectionFactory) + public QueryCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) { _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); Options.Add("q=|query=", "The query to execute", v => _query = v); _range = Enable(); _signal = Enable(); Options.Add("timeout=", "The query execution timeout in milliseconds", v => _timeoutMS = int.Parse(v)); + Options.Add("no-color", "Don't colorize text output", v => _noColor = true); + _noColor = config.Output.DisableColor; _connection = Enable(); } @@ -58,7 +63,14 @@ protected override async Task Run() // remove the `.Value` when _Seq.Api_ is updated to reflect this. // ReSharper disable once PossibleInvalidOperationException var result = await QueryCsvAsync(connection, _query, _range.Start, _range.End, _signal.Signal, _timeoutMS); - Console.WriteLine(result); + if (_noColor) + { + Console.Write(result); + return 0; + } + + var tokens = new CsvTokenizer().Tokenize(result); + CsvWriter.WriteCsv(tokens, OutputFormatFeature.ConsoleTheme, Console.Out, true); return 0; } diff --git a/src/SeqCli/Cli/Features/OutputFormatFeature.cs b/src/SeqCli/Cli/Features/OutputFormatFeature.cs index 297332db..ca622f92 100644 --- a/src/SeqCli/Cli/Features/OutputFormatFeature.cs +++ b/src/SeqCli/Cli/Features/OutputFormatFeature.cs @@ -30,6 +30,8 @@ public OutputFormatFeature(SeqCliOutputConfig outputConfig) _noColor = outputConfig.DisableColor; } + public static ConsoleTheme ConsoleTheme => SystemConsoleTheme.Literate; + public override void Enable(OptionSet options) { options.Add( @@ -50,7 +52,7 @@ public Logger CreateOutputLogger() else outputConfiguration.WriteTo.Console( outputTemplate: "[{Timestamp:o} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}", - theme: _noColor ? ConsoleTheme.None : SystemConsoleTheme.Literate); + theme: _noColor ? ConsoleTheme.None : ConsoleTheme); return outputConfiguration.CreateLogger(); } diff --git a/src/SeqCli/Csv/CsvToken.cs b/src/SeqCli/Csv/CsvToken.cs new file mode 100644 index 00000000..dd2d18be --- /dev/null +++ b/src/SeqCli/Csv/CsvToken.cs @@ -0,0 +1,15 @@ +namespace SeqCli.Csv +{ + enum CsvToken + { + None, + Newline, + DoubleQuote, + Comma, + Number, + Boolean, + Null, + Text, + EscapedDoubleQuote + } +} diff --git a/src/SeqCli/Csv/CsvTokenizer.cs b/src/SeqCli/Csv/CsvTokenizer.cs new file mode 100644 index 00000000..e7ec7411 --- /dev/null +++ b/src/SeqCli/Csv/CsvTokenizer.cs @@ -0,0 +1,143 @@ +using System.Collections.Generic; +using System.Globalization; +using Superpower; +using Superpower.Model; +using Superpower.Parsers; + +namespace SeqCli.Csv +{ + class CsvTokenizer : Tokenizer + { + static readonly TextParser Content = Span.While(ch => ch != '"'); + + protected override IEnumerable> Tokenize(TextSpan span) + { + var next = SkipInsignificant(span); + if (!next.HasValue) + yield break; + + do + { + // Here we're always either at the beginning of a line, or behind a comma. + if (next.Value == '"') + { + yield return Result.Value(CsvToken.DoubleQuote, next.Location, next.Remainder); + next = next.Remainder.ConsumeChar(); + if (!next.HasValue) yield break; + + var text = Content(next.Location); + while (text.HasValue) + { + if (text.Value.Length > 0) + { + if (TryMatchSpecialContent(text.Value, out var specialTokenType) && + !IsEscapedDoubleQuote(text.Remainder)) + yield return Result.Value(specialTokenType, text.Location, text.Remainder); + else + yield return Result.Value(CsvToken.Text, text.Location, text.Remainder); + } + + next = text.Remainder.ConsumeChar(); + if (!next.HasValue) yield break; + + if (next.Value != '"') + { + yield return Result.Empty(next.Location, new[] {"double-quote"}); + yield break; + } + + var lookahead = next.Remainder.ConsumeChar(); + if (lookahead.HasValue && lookahead.Value == '"') + { + yield return Result.Value(CsvToken.EscapedDoubleQuote, next.Location, lookahead.Remainder); + next = lookahead.Remainder.ConsumeChar(); + if (!next.HasValue) yield break; + } + else + { + yield return Result.Value(CsvToken.DoubleQuote, next.Location, next.Remainder); + next = next.Remainder.ConsumeChar(); + if (!next.HasValue) yield break; + break; // Done with the content + } + + text = Content(next.Location); + } + + next = SkipInsignificant(next.Location); + if (next.Value == ',') + { + yield return Result.Value(CsvToken.Comma, next.Location, next.Remainder); + next = next.Remainder.ConsumeChar(); + if (!next.HasValue) yield break; + } + else if (next.Value == '\n') + { + yield return Result.Value(CsvToken.Newline, next.Location, next.Remainder); + next = next.Remainder.ConsumeChar(); + if (!next.HasValue) yield break; + } + else + { + yield return Result.Empty(next.Location, new[] {"comma", "newline"}); + yield break; + } + } + else + { + yield return Result.Empty(next.Location, new[] {"double-quote"}); + yield break; + } + + next = SkipInsignificant(next.Location); + } while (next.HasValue); + } + + static bool IsEscapedDoubleQuote(TextSpan span) + { + return span.Length >= 2 && + span.Source[span.Position.Absolute] == '"' && + span.Source[span.Position.Absolute + 1] == '"'; + } + + static bool TryMatchSpecialContent(TextSpan text, out CsvToken specialTokenType) + { + // Planning a switch from "True" to "true" for CSV Booleans + if (text.EqualsValueIgnoreCase("true") || + text.EqualsValueIgnoreCase("false")) + { + specialTokenType = CsvToken.Boolean; + return true; + } + + if (text.EqualsValue("null")) + { + specialTokenType = CsvToken.Null; + return true; + } + + // Just a quick temp job here until Superpower `Numerics` gets `Decimal` and `HexNumber`, plus + // an `IsMatch(TextSpan)` on `TextParser`. + var s = text.ToStringValue(); + if (text.Length > 0 + && text.Length < 16 + && (decimal.TryParse(text.ToStringValue(), out _) || + s.StartsWith("0x") && ulong.TryParse(s.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out _))) + { + specialTokenType = CsvToken.Number; + return true; + } + + specialTokenType = CsvToken.None; + return false; + } + + static Result SkipInsignificant(TextSpan span) + { + var result = span.ConsumeChar(); + while (result.HasValue && result.Value != '\n' && char.IsWhiteSpace(result.Value)) + result = result.Remainder.ConsumeChar(); + return result; + } + } +} \ No newline at end of file diff --git a/src/SeqCli/Csv/CsvWriter.cs b/src/SeqCli/Csv/CsvWriter.cs new file mode 100644 index 00000000..0e135d3e --- /dev/null +++ b/src/SeqCli/Csv/CsvWriter.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Serilog.Sinks.SystemConsole.Themes; +using Superpower.Model; + +namespace SeqCli.Csv +{ + static class CsvWriter + { + public static void WriteCsv(IEnumerable> csv, ConsoleTheme theme, TextWriter output, bool hasHeaderRow) + { + var isHeaderRow = hasHeaderRow; + + foreach (var token in csv) + { + switch (token.Kind) + { + case CsvToken.Newline: + output.WriteLine(); + isHeaderRow = false; + break; + case CsvToken.Comma: + theme.Set(output, ConsoleThemeStyle.TertiaryText); + output.Write(','); + theme.Reset(output); + break; + case CsvToken.DoubleQuote: + theme.Set(output, ConsoleThemeStyle.TertiaryText); + output.Write('"'); + theme.Reset(output); + break; + case CsvToken.Boolean: + theme.Set(output, ConsoleThemeStyle.Boolean); + output.Write(token.ToStringValue()); + theme.Reset(output); + break; + case CsvToken.Null: + theme.Set(output, ConsoleThemeStyle.Null); + output.Write(token.ToStringValue()); + theme.Reset(output); + break; + case CsvToken.Number: + theme.Set(output, ConsoleThemeStyle.Number); + output.Write(token.ToStringValue()); + theme.Reset(output); + break; + case CsvToken.EscapedDoubleQuote: + theme.Set(output, ConsoleThemeStyle.Scalar); + output.Write(token.ToStringValue()); + theme.Reset(output); + break; + case CsvToken.Text: + theme.Set(output, isHeaderRow ? ConsoleThemeStyle.Name : ConsoleThemeStyle.Text); + output.Write(token.ToStringValue()); + theme.Reset(output); + break; + default: + throw new ArgumentException($"Unrecognized token `{token}`."); + } + } + } + } +} \ No newline at end of file diff --git a/src/SeqCli/SeqCli.csproj b/src/SeqCli/SeqCli.csproj index 1d398268..65e0c9f3 100644 --- a/src/SeqCli/SeqCli.csproj +++ b/src/SeqCli/SeqCli.csproj @@ -29,6 +29,7 @@ + diff --git a/test/SeqCli.Tests/Csv/CsvTokenizerTests.cs b/test/SeqCli.Tests/Csv/CsvTokenizerTests.cs new file mode 100644 index 00000000..6862ea21 --- /dev/null +++ b/test/SeqCli.Tests/Csv/CsvTokenizerTests.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.Linq; +using SeqCli.Csv; +using Superpower.Model; +using Xunit; + +namespace SeqCli.Tests.Csv +{ + public class CsvTokenizerTests + { + [Fact] + public void AnEmptyStringYieldsNoTokens() + { + Assert.Empty(Tokenize("")); + } + + [Fact] + public void TokenizesATextCell() + { + var tokens = Tokenize("\"abc\""); + Assert.Equal(tokens.Count(), 3); + Assert.Equal(tokens.Select(t => t.Kind), new[]{CsvToken.DoubleQuote, CsvToken.Text, CsvToken.DoubleQuote}); + } + + [Fact] + public void TokenizesARow() + { + var tokens = Tokenize("\"abc\",\"def\"\r\n"); + Assert.Equal(tokens.Count(), 8); + } + + [Theory] + [InlineData(CsvToken.Text, "abc")] + [InlineData(CsvToken.Text, "1a")] + [InlineData(CsvToken.Text, "1\"\"")] + [InlineData(CsvToken.Number, "1")] + [InlineData(CsvToken.Number, "-123.45")] + [InlineData(CsvToken.Number, "0xa123")] + [InlineData(CsvToken.Boolean, "true")] + [InlineData(CsvToken.Boolean, "false")] + [InlineData(CsvToken.Null, "null")] + public void DetectsSpecialTokenTypes(object o, string cell) + { + var tokenKind = (CsvToken) o; + var content = Tokenize($"\"{cell}\"").ElementAt(1); + Assert.Equal(tokenKind, content.Kind); + } + + static TokenList Tokenize(string csv) + { + return new CsvTokenizer().Tokenize(csv); + } + } +} \ No newline at end of file diff --git a/test/SeqCli.Tests/SeqCli.Tests.csproj b/test/SeqCli.Tests/SeqCli.Tests.csproj index b0cb118d..0c92e84d 100644 --- a/test/SeqCli.Tests/SeqCli.Tests.csproj +++ b/test/SeqCli.Tests/SeqCli.Tests.csproj @@ -1,22 +1,17 @@  - - - netcoreapp2.0 + + netcoreapp2.0 x64 - - - - - + \ No newline at end of file