diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 554d4fc..ce2356b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -70,6 +70,7 @@ jobs: dotnet example panels dotnet example colors dotnet example emojis + dotnet example exceptions - name: Build shell: bash diff --git a/docs/input/assets/images/compact_exception.png b/docs/input/assets/images/compact_exception.png new file mode 100644 index 0000000..a0fd051 Binary files /dev/null and b/docs/input/assets/images/compact_exception.png differ diff --git a/docs/input/assets/images/exception.png b/docs/input/assets/images/exception.png new file mode 100644 index 0000000..8f5dcab Binary files /dev/null and b/docs/input/assets/images/exception.png differ diff --git a/docs/input/exceptions.md b/docs/input/exceptions.md new file mode 100644 index 0000000..3ac7231 --- /dev/null +++ b/docs/input/exceptions.md @@ -0,0 +1,26 @@ +Title: Exceptions +Order: 3 +--- + +Exceptions isn't always readable when viewed in the terminal. +You can make exception a bit more readable by using the `WriteException` method. + +```csharp +AnsiConsole.WriteException(ex); +``` + + + + + +You can also shorten specific parts of the exception to make it even +more readable, and make paths clickable hyperlinks. Whether or not +the hyperlinks are clickable is up to the terminal. + +```csharp +AnsiConsole.WriteException(ex, + ExceptionFormat.ShortenPaths | ExceptionFormat.ShortenTypes | + ExceptionFormat.ShortenMethods | ExceptionFormat.ShowLinks); +``` + + diff --git a/docs/input/markup.md b/docs/input/markup.md index acc8c1f..87f3d22 100644 --- a/docs/input/markup.md +++ b/docs/input/markup.md @@ -65,6 +65,15 @@ For a list of emoji, see the [Emojis](xref:styles) appendix section. # Colors +In the examples above, all colors was referenced by their name, +but you can also use the hex or rgb representation for colors in markdown. + +```csharp +AnsiConsole.Markup("[red]Foo[/] "); +AnsiConsole.Markup("[#ff0000]Bar[/] "); +AnsiConsole.Markup("[rgb(255,0,0)]Baz[/] "); +``` + For a list of colors, see the [Colors](xref:colors) appendix section. # Styles diff --git a/examples/Exceptions/Exceptions.csproj b/examples/Exceptions/Exceptions.csproj new file mode 100644 index 0000000..19d600a --- /dev/null +++ b/examples/Exceptions/Exceptions.csproj @@ -0,0 +1,15 @@ + + + + Exe + netcoreapp3.1 + false + Exceptions + Demonstrates how to render formatted exceptions. + + + + + + + diff --git a/examples/Exceptions/Program.cs b/examples/Exceptions/Program.cs new file mode 100644 index 0000000..7e10289 --- /dev/null +++ b/examples/Exceptions/Program.cs @@ -0,0 +1,42 @@ +using System; +using System.Security.Authentication; +using Spectre.Console; + +namespace Exceptions +{ + public static class Program + { + public static void Main(string[] args) + { + try + { + DoMagic(42, null); + } + catch (Exception ex) + { + AnsiConsole.WriteLine(); + AnsiConsole.WriteException(ex); + + AnsiConsole.WriteLine(); + AnsiConsole.WriteException(ex, ExceptionFormats.ShortenEverything | ExceptionFormats.ShowLinks); + } + } + + private static void DoMagic(int foo, string[,] bar) + { + try + { + CheckCredentials(foo, bar); + } + catch(Exception ex) + { + throw new InvalidOperationException("Whaaat?", ex); + } + } + + private static void CheckCredentials(int qux, string[,] corgi) + { + throw new InvalidCredentialException("The credentials are invalid."); + } + } +} diff --git a/src/Spectre.Console.Tests/.editorconfig b/src/Spectre.Console.Tests/.editorconfig index 90a63b8..5b36435 100644 --- a/src/Spectre.Console.Tests/.editorconfig +++ b/src/Spectre.Console.Tests/.editorconfig @@ -24,3 +24,6 @@ dotnet_diagnostic.CA2000.severity = none # SA1118: Parameter should not span multiple lines dotnet_diagnostic.SA1118.severity = none + +# CA1031: Do not catch general exception types +dotnet_diagnostic.CA1031.severity = none diff --git a/src/Spectre.Console.Tests/Data/Exceptions.cs b/src/Spectre.Console.Tests/Data/Exceptions.cs new file mode 100644 index 0000000..e79c84b --- /dev/null +++ b/src/Spectre.Console.Tests/Data/Exceptions.cs @@ -0,0 +1,23 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Spectre.Console.Tests.Data +{ + public static class TestExceptions + { + [SuppressMessage("Usage", "CA1801:Review unused parameters", Justification = "")] + public static bool MethodThatThrows(int? number) => throw new InvalidOperationException("Throwing!"); + + public static void ThrowWithInnerException() + { + try + { + MethodThatThrows(null); + } + catch (Exception ex) + { + throw new InvalidOperationException("Something threw!", ex); + } + } + } +} diff --git a/src/Spectre.Console.Tests/Extensions/StringExtensions.cs b/src/Spectre.Console.Tests/Extensions/StringExtensions.cs index 7319815..66446c8 100644 --- a/src/Spectre.Console.Tests/Extensions/StringExtensions.cs +++ b/src/Spectre.Console.Tests/Extensions/StringExtensions.cs @@ -1,13 +1,34 @@ using System; +using System.Text.RegularExpressions; namespace Spectre.Console.Tests { public static class StringExtensions { + private static readonly Regex _lineNumberRegex = new Regex(":\\d+", RegexOptions.Singleline); + private static readonly Regex _filenameRegex = new Regex("\\sin\\s.*cs:nn", RegexOptions.Multiline); + public static string NormalizeLineEndings(this string text) { return text?.Replace("\r\n", "\n", StringComparison.OrdinalIgnoreCase) ?.Replace("\r", string.Empty, StringComparison.OrdinalIgnoreCase); } + + public static string NormalizeStackTrace(this string text) + { + text = _lineNumberRegex.Replace(text, match => + { + return ":nn"; + }); + + return _filenameRegex.Replace(text, match => + { + var value = match.Value; + var index = value.LastIndexOfAny(new[] { '\\', '/' }); + var filename = value.Substring(index + 1, value.Length - index - 1); + + return $" in /xyz/{filename}"; + }); + } } } diff --git a/src/Spectre.Console.Tests/Tools/MarkupConsoleFixture.cs b/src/Spectre.Console.Tests/Tools/MarkupConsoleFixture.cs new file mode 100644 index 0000000..c9b9be5 --- /dev/null +++ b/src/Spectre.Console.Tests/Tools/MarkupConsoleFixture.cs @@ -0,0 +1,44 @@ +using System; +using System.IO; +using System.Text; +using Spectre.Console.Rendering; + +namespace Spectre.Console.Tests.Tools +{ + public sealed class MarkupConsoleFixture : IDisposable, IAnsiConsole + { + private readonly StringWriter _writer; + private readonly IAnsiConsole _console; + + public string Output => _writer.ToString().TrimEnd('\n'); + + public Capabilities Capabilities => _console.Capabilities; + public Encoding Encoding => _console.Encoding; + public int Width { get; } + public int Height => _console.Height; + + public MarkupConsoleFixture(ColorSystem system, AnsiSupport ansi = AnsiSupport.Yes, int width = 80) + { + _writer = new StringWriter(); + _console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = ansi, + ColorSystem = (ColorSystemSupport)system, + Out = _writer, + LinkIdentityGenerator = new TestLinkIdentityGenerator(), + }); + + Width = width; + } + + public void Dispose() + { + _writer?.Dispose(); + } + + public void Write(Segment segment) + { + _console.Write(segment); + } + } +} diff --git a/src/Spectre.Console.Tests/Tools/PlainConsole.cs b/src/Spectre.Console.Tests/Tools/PlainConsole.cs index e793b28..b0928ae 100644 --- a/src/Spectre.Console.Tests/Tools/PlainConsole.cs +++ b/src/Spectre.Console.Tests/Tools/PlainConsole.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; using Spectre.Console.Rendering; @@ -50,5 +51,16 @@ namespace Spectre.Console.Tests Writer.Write(segment.Text); } + + public string[] WriteExceptionAndGetLines(Exception ex, ExceptionFormats formats = ExceptionFormats.None) + { + this.WriteException(ex, formats); + + return Output.NormalizeStackTrace() + .NormalizeLineEndings() + .Split(new char[] { '\n' }) + .Select(line => line.TrimEnd()) + .ToArray(); + } } } diff --git a/src/Spectre.Console.Tests/Unit/ExceptionTests.cs b/src/Spectre.Console.Tests/Unit/ExceptionTests.cs new file mode 100644 index 0000000..43d9fac --- /dev/null +++ b/src/Spectre.Console.Tests/Unit/ExceptionTests.cs @@ -0,0 +1,99 @@ +using System; +using Shouldly; +using Spectre.Console.Tests.Data; +using Xunit; + +namespace Spectre.Console.Tests.Unit +{ + public sealed class ExceptionTests + { + [Fact] + public void Should_Write_Exception() + { + // Given + var console = new PlainConsole(width: 1024); + var dex = GetException(() => TestExceptions.MethodThatThrows(null)); + + // When + var result = console.WriteExceptionAndGetLines(dex); + + // Then + result.Length.ShouldBe(4); + result[0].ShouldBe("System.InvalidOperationException: Throwing!"); + result[1].ShouldBe(" at Spectre.Console.Tests.Data.TestExceptions.MethodThatThrows(Nullable`1 number) in /xyz/Exceptions.cs:nn"); + result[2].ShouldBe(" at Spectre.Console.Tests.Unit.ExceptionTests.<>c.b__0_0() in /xyz/ExceptionTests.cs:nn"); + result[3].ShouldBe(" at Spectre.Console.Tests.Unit.ExceptionTests.GetException(Action action) in /xyz/ExceptionTests.cs:nn"); + } + + [Fact] + public void Should_Write_Exception_With_Shortened_Types() + { + // Given + var console = new PlainConsole(width: 1024); + var dex = GetException(() => TestExceptions.MethodThatThrows(null)); + + // When + var result = console.WriteExceptionAndGetLines(dex, ExceptionFormats.ShortenTypes); + + // Then + result.Length.ShouldBe(4); + result[0].ShouldBe("InvalidOperationException: Throwing!"); + result[1].ShouldBe(" at Spectre.Console.Tests.Data.TestExceptions.MethodThatThrows(Nullable`1 number) in /xyz/Exceptions.cs:nn"); + result[2].ShouldBe(" at Spectre.Console.Tests.Unit.ExceptionTests.<>c.b__1_0() in /xyz/ExceptionTests.cs:nn"); + result[3].ShouldBe(" at Spectre.Console.Tests.Unit.ExceptionTests.GetException(Action action) in /xyz/ExceptionTests.cs:nn"); + } + + [Fact] + public void Should_Write_Exception_With_Shortened_Methods() + { + // Given + var console = new PlainConsole(width: 1024); + var dex = GetException(() => TestExceptions.MethodThatThrows(null)); + + // When + var result = console.WriteExceptionAndGetLines(dex, ExceptionFormats.ShortenMethods); + + // Then + result.Length.ShouldBe(4); + result[0].ShouldBe("System.InvalidOperationException: Throwing!"); + result[1].ShouldBe(" at MethodThatThrows(Nullable`1 number) in /xyz/Exceptions.cs:nn"); + result[2].ShouldBe(" at b__2_0() in /xyz/ExceptionTests.cs:nn"); + result[3].ShouldBe(" at GetException(Action action) in /xyz/ExceptionTests.cs:nn"); + } + + [Fact] + public void Should_Write_Exception_With_Inner_Exception() + { + // Given + var console = new PlainConsole(width: 1024); + var dex = GetException(() => TestExceptions.ThrowWithInnerException()); + + // When + var result = console.WriteExceptionAndGetLines(dex); + + // Then + result.Length.ShouldBe(7); + result[0].ShouldBe("System.InvalidOperationException: Something threw!"); + result[1].ShouldBe(" System.InvalidOperationException: Throwing!"); + result[2].ShouldBe(" at Spectre.Console.Tests.Data.TestExceptions.MethodThatThrows(Nullable`1 number) in /xyz/Exceptions.cs:nn"); + result[3].ShouldBe(" at Spectre.Console.Tests.Data.TestExceptions.ThrowWithInnerException() in /xyz/Exceptions.cs:nn"); + result[4].ShouldBe(" at Spectre.Console.Tests.Data.TestExceptions.ThrowWithInnerException() in /xyz/Exceptions.cs:nn"); + result[5].ShouldBe(" at Spectre.Console.Tests.Unit.ExceptionTests.<>c.b__3_0() in /xyz/ExceptionTests.cs:nn"); + result[6].ShouldBe(" at Spectre.Console.Tests.Unit.ExceptionTests.GetException(Action action) in /xyz/ExceptionTests.cs:nn"); + } + + public static Exception GetException(Action action) + { + try + { + action?.Invoke(); + } + catch (Exception e) + { + return e; + } + + throw new InvalidOperationException("Exception harness failed"); + } + } +} diff --git a/src/Spectre.Console.Tests/Unit/PanelTests.cs b/src/Spectre.Console.Tests/Unit/PanelTests.cs index 909d7ae..6a36c43 100644 --- a/src/Spectre.Console.Tests/Unit/PanelTests.cs +++ b/src/Spectre.Console.Tests/Unit/PanelTests.cs @@ -1,4 +1,6 @@ +using System.Collections.Generic; using Shouldly; +using Spectre.Console.Rendering; using Xunit; namespace Spectre.Console.Tests.Unit @@ -298,5 +300,33 @@ namespace Spectre.Console.Tests.Unit console.Lines[3].ShouldBe("│ └─────────────┘ │"); console.Lines[4].ShouldBe("└─────────────────┘"); } + + [Fact] + public void Should_Wrap_Content_Correctly() + { + // Given + var console = new PlainConsole(width: 84); + var rows = new List(); + var grid = new Grid(); + grid.AddColumn(new GridColumn().PadLeft(2).PadRight(0)); + grid.AddColumn(new GridColumn().PadLeft(1).PadRight(0)); + grid.AddRow("at", "[grey]System.Runtime.CompilerServices.TaskAwaiter.[/][yellow]HandleNonSuccessAndDebuggerNotification[/]([blue]Task[/] task)"); + rows.Add(grid); + + var panel = new Panel(grid) + .Expand().RoundedBorder() + .SetBorderStyle(Style.WithForeground(Color.Grey)) + .SetHeader("Short paths ", Style.WithForeground(Color.Grey)); + + // When + console.Render(panel); + + // Then + console.Lines.Count.ShouldBe(4); + console.Lines[0].ShouldBe("╭─Short paths ─────────────────────────────────────────────────────────────────────╮"); + console.Lines[1].ShouldBe("│ at System.Runtime.CompilerServices.TaskAwaiter. │"); + console.Lines[2].ShouldBe("│ HandleNonSuccessAndDebuggerNotification(Task task) │"); + console.Lines[3].ShouldBe("╰──────────────────────────────────────────────────────────────────────────────────╯"); + } } } diff --git a/src/Spectre.Console.sln b/src/Spectre.Console.sln index 25fe573..9f465ac 100644 --- a/src/Spectre.Console.sln +++ b/src/Spectre.Console.sln @@ -35,6 +35,15 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Links", "..\examples\Links\ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Emojis", "..\examples\Emojis\Emojis.csproj", "{1EABB956-957F-4C1A-8AC0-FD19C8F3C2F2}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Exceptions", "..\examples\Exceptions\Exceptions.csproj", "{90C081A7-7C1D-4A4A-82B6-8FF473C3EA32}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "GitHub", "GitHub", "{C3E2CB5C-1517-4C75-B59A-93D4E22BEC8D}" + ProjectSection(SolutionItems) = preProject + ..\.github\workflows\ci.yaml = ..\.github\workflows\ci.yaml + ..\.github\workflows\docs.yaml = ..\.github\workflows\docs.yaml + ..\.github\workflows\publish.yaml = ..\.github\workflows\publish.yaml + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -177,6 +186,18 @@ Global {1EABB956-957F-4C1A-8AC0-FD19C8F3C2F2}.Release|x64.Build.0 = Release|Any CPU {1EABB956-957F-4C1A-8AC0-FD19C8F3C2F2}.Release|x86.ActiveCfg = Release|Any CPU {1EABB956-957F-4C1A-8AC0-FD19C8F3C2F2}.Release|x86.Build.0 = Release|Any CPU + {90C081A7-7C1D-4A4A-82B6-8FF473C3EA32}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {90C081A7-7C1D-4A4A-82B6-8FF473C3EA32}.Debug|Any CPU.Build.0 = Debug|Any CPU + {90C081A7-7C1D-4A4A-82B6-8FF473C3EA32}.Debug|x64.ActiveCfg = Debug|Any CPU + {90C081A7-7C1D-4A4A-82B6-8FF473C3EA32}.Debug|x64.Build.0 = Debug|Any CPU + {90C081A7-7C1D-4A4A-82B6-8FF473C3EA32}.Debug|x86.ActiveCfg = Debug|Any CPU + {90C081A7-7C1D-4A4A-82B6-8FF473C3EA32}.Debug|x86.Build.0 = Debug|Any CPU + {90C081A7-7C1D-4A4A-82B6-8FF473C3EA32}.Release|Any CPU.ActiveCfg = Release|Any CPU + {90C081A7-7C1D-4A4A-82B6-8FF473C3EA32}.Release|Any CPU.Build.0 = Release|Any CPU + {90C081A7-7C1D-4A4A-82B6-8FF473C3EA32}.Release|x64.ActiveCfg = Release|Any CPU + {90C081A7-7C1D-4A4A-82B6-8FF473C3EA32}.Release|x64.Build.0 = Release|Any CPU + {90C081A7-7C1D-4A4A-82B6-8FF473C3EA32}.Release|x86.ActiveCfg = Release|Any CPU + {90C081A7-7C1D-4A4A-82B6-8FF473C3EA32}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -191,6 +212,8 @@ Global {094245E6-4C94-485D-B5AC-3153E878B112} = {F0575243-121F-4DEE-9F6B-246E26DC0844} {6AF8C93B-AA41-4F44-8B1B-B8D166576174} = {F0575243-121F-4DEE-9F6B-246E26DC0844} {1EABB956-957F-4C1A-8AC0-FD19C8F3C2F2} = {F0575243-121F-4DEE-9F6B-246E26DC0844} + {90C081A7-7C1D-4A4A-82B6-8FF473C3EA32} = {F0575243-121F-4DEE-9F6B-246E26DC0844} + {C3E2CB5C-1517-4C75-B59A-93D4E22BEC8D} = {20595AD4-8D75-4AF8-B6BC-9C38C160423F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5729B071-67A0-48FB-8B1B-275E6822086C} diff --git a/src/Spectre.Console/AnsiConsole.Exceptions.cs b/src/Spectre.Console/AnsiConsole.Exceptions.cs new file mode 100644 index 0000000..a6ec8be --- /dev/null +++ b/src/Spectre.Console/AnsiConsole.Exceptions.cs @@ -0,0 +1,20 @@ +using System; + +namespace Spectre.Console +{ + /// + /// A console capable of writing ANSI escape sequences. + /// + public static partial class AnsiConsole + { + /// + /// Writes an exception to the console. + /// + /// The exception to write to the console. + /// The exception format options. + public static void WriteException(Exception exception, ExceptionFormats format = ExceptionFormats.None) + { + Console.WriteException(exception, format); + } + } +} diff --git a/src/Spectre.Console/AnsiConsole.State.cs b/src/Spectre.Console/AnsiConsole.State.cs index 31e0503..f3bb03b 100644 --- a/src/Spectre.Console/AnsiConsole.State.cs +++ b/src/Spectre.Console/AnsiConsole.State.cs @@ -1,7 +1,3 @@ -using System; -using System.IO; -using Spectre.Console.Internal; - namespace Spectre.Console { /// @@ -9,9 +5,6 @@ namespace Spectre.Console /// public static partial class AnsiConsole { - private static ConsoleColor _defaultForeground; - private static ConsoleColor _defaultBackground; - internal static Style CurrentStyle { get; private set; } = Style.Plain; internal static bool Created { get; private set; } @@ -42,20 +35,6 @@ namespace Spectre.Console set => CurrentStyle = CurrentStyle.WithDecoration(value); } - internal static void Initialize(TextWriter? @out) - { - if (@out?.IsStandardOut() ?? false) - { - Foreground = _defaultForeground = System.Console.ForegroundColor; - Background = _defaultBackground = System.Console.BackgroundColor; - } - else - { - Foreground = _defaultForeground = Color.Silver; - Background = _defaultBackground = Color.Black; - } - } - /// /// Resets colors and text decorations. /// @@ -78,8 +57,7 @@ namespace Spectre.Console /// public static void ResetColors() { - Foreground = _defaultForeground; - Background = _defaultBackground; + CurrentStyle = Style.Plain; } } } diff --git a/src/Spectre.Console/AnsiConsole.cs b/src/Spectre.Console/AnsiConsole.cs index 40ae22d..f1d09e1 100644 --- a/src/Spectre.Console/AnsiConsole.cs +++ b/src/Spectre.Console/AnsiConsole.cs @@ -16,7 +16,6 @@ namespace Spectre.Console ColorSystem = ColorSystemSupport.Detect, Out = System.Console.Out, }); - Initialize(System.Console.Out); Created = true; return console; }); diff --git a/src/Spectre.Console/Color.cs b/src/Spectre.Console/Color.cs index b58da6c..f79d452 100644 --- a/src/Spectre.Console/Color.cs +++ b/src/Spectre.Console/Color.cs @@ -245,9 +245,32 @@ namespace Spectre.Console }; } + /// + /// Converts the color to a markup string. + /// + /// A representing the color as markup. + public string ToMarkupString() + { + if (Number != null) + { + var name = ColorTable.GetName(Number.Value); + if (!string.IsNullOrWhiteSpace(name)) + { + return name; + } + } + + return string.Format(CultureInfo.InvariantCulture, "#{0:X2}{1:X2}{2:X2}", R, G, B); + } + /// public override string ToString() { + if (IsDefault) + { + return "default"; + } + if (Number != null) { var name = ColorTable.GetName(Number.Value); diff --git a/src/Spectre.Console/Emoji.cs b/src/Spectre.Console/Emoji.cs index 0d9e604..f9795c1 100644 --- a/src/Spectre.Console/Emoji.cs +++ b/src/Spectre.Console/Emoji.cs @@ -16,7 +16,17 @@ namespace Spectre.Console /// A string with emoji codes replaced with actual emoji. public static string Replace(string value) { - static string ReplaceEmoji(Match match) => _emojis[match.Groups[2].Value]; + static string ReplaceEmoji(Match match) + { + var key = match.Groups[2].Value; + if (_emojis.TryGetValue(key, out var emoji)) + { + return emoji; + } + + return match.Value; + } + return _emojiCode.Replace(value, ReplaceEmoji); } } diff --git a/src/Spectre.Console/ExceptionFormat.cs b/src/Spectre.Console/ExceptionFormat.cs new file mode 100644 index 0000000..54e361a --- /dev/null +++ b/src/Spectre.Console/ExceptionFormat.cs @@ -0,0 +1,41 @@ +using System; + +namespace Spectre.Console +{ + /// + /// Represents how an exception is formatted. + /// + [Flags] + public enum ExceptionFormats + { + /// + /// The default formatting. + /// + None = 0, + + /// + /// Whether or not paths should be shortened. + /// + ShortenPaths = 1, + + /// + /// Whether or not types should be shortened. + /// + ShortenTypes = 2, + + /// + /// Whether or not methods should be shortened. + /// + ShortenMethods = 4, + + /// + /// Whether or not to show paths as links in the terminal. + /// + ShowLinks = 8, + + /// + /// Shortens everything that can be shortened. + /// + ShortenEverything = ShortenMethods | ShortenTypes | ShortenPaths, + } +} diff --git a/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Exceptions.cs b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Exceptions.cs new file mode 100644 index 0000000..a50005e --- /dev/null +++ b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Exceptions.cs @@ -0,0 +1,21 @@ +using System; + +namespace Spectre.Console +{ + /// + /// Contains extension methods for . + /// + public static partial class AnsiConsoleExtensions + { + /// + /// Writes an exception to the console. + /// + /// The console. + /// The exception to write to the console. + /// The exception format options. + public static void WriteException(this IAnsiConsole console, Exception exception, ExceptionFormats format = ExceptionFormats.None) + { + Render(console, exception.GetRenderable(format)); + } + } +} diff --git a/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Rendering.cs b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Rendering.cs index dec7b3c..72d92b4 100644 --- a/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Rendering.cs +++ b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Rendering.cs @@ -27,10 +27,9 @@ namespace Spectre.Console } var options = new RenderContext(console.Encoding, console.Capabilities.LegacyConsole); - var segments = renderable.Render(options, console.Width).Where(x => !(x.Text.Length == 0 && !x.IsLineBreak)).ToArray(); + var segments = renderable.Render(options, console.Width).ToArray(); segments = Segment.Merge(segments).ToArray(); - var current = Style.Plain; foreach (var segment in segments) { if (string.IsNullOrEmpty(segment.Text)) diff --git a/src/Spectre.Console/Extensions/ExceptionExtensions.cs b/src/Spectre.Console/Extensions/ExceptionExtensions.cs new file mode 100644 index 0000000..1c4d73a --- /dev/null +++ b/src/Spectre.Console/Extensions/ExceptionExtensions.cs @@ -0,0 +1,22 @@ +using System; +using Spectre.Console.Rendering; + +namespace Spectre.Console +{ + /// + /// Contains extension methods for . + /// + public static class ExceptionExtensions + { + /// + /// Gets a representation of the exception. + /// + /// The exception to format. + /// The exception format options. + /// A representing the exception. + public static IRenderable GetRenderable(this Exception exception, ExceptionFormats format = ExceptionFormats.None) + { + return ExceptionFormatter.Format(exception, format); + } + } +} diff --git a/src/Spectre.Console/Extensions/StringExtensions.cs b/src/Spectre.Console/Extensions/StringExtensions.cs new file mode 100644 index 0000000..50e6efa --- /dev/null +++ b/src/Spectre.Console/Extensions/StringExtensions.cs @@ -0,0 +1,26 @@ +namespace Spectre.Console +{ + /// + /// Contains extension methods for . + /// + public static class StringExtensions + { + /// + /// Converts the string to something that is safe to + /// use in a markup string. + /// + /// The text to convert. + /// A string that is safe to use in a markup string. + public static string SafeMarkup(this string text) + { + if (text == null) + { + return string.Empty; + } + + return text + .Replace("[", "[[") + .Replace("]", "]]"); + } + } +} diff --git a/src/Spectre.Console/Internal/ExceptionFormatter.cs b/src/Spectre.Console/Internal/ExceptionFormatter.cs new file mode 100644 index 0000000..2a305b2 --- /dev/null +++ b/src/Spectre.Console/Internal/ExceptionFormatter.cs @@ -0,0 +1,159 @@ +using System; +using System.Linq; +using System.Text; +using Spectre.Console.Internal; +using Spectre.Console.Rendering; + +namespace Spectre.Console +{ + internal static class ExceptionFormatter + { + private static readonly Color _typeColor = Color.White; + private static readonly Color _methodColor = Color.Yellow; + private static readonly Color _parameterColor = Color.Blue; + private static readonly Color _pathColor = Color.Yellow; + private static readonly Color _dimmedColor = Color.Grey; + + public static IRenderable Format(Exception exception, ExceptionFormats format) + { + if (exception is null) + { + throw new ArgumentNullException(nameof(exception)); + } + + var info = ExceptionParser.Parse(exception.ToString()); + if (info == null) + { + return new Text(exception.ToString()); + } + + return GetException(info, format); + } + + private static IRenderable GetException(ExceptionInfo info, ExceptionFormats format) + { + if (info is null) + { + throw new ArgumentNullException(nameof(info)); + } + + return new Rows(new IRenderable[] + { + GetMessage(info, format), + GetStackFrames(info, format), + }).Expand(); + } + + private static Markup GetMessage(ExceptionInfo ex, ExceptionFormats format) + { + var shortenTypes = (format & ExceptionFormats.ShortenTypes) != 0; + var type = Emphasize(ex.Type, new[] { '.' }, _typeColor.ToMarkupString(), shortenTypes); + var message = $"[b red]{ex.Message.SafeMarkup()}[/]"; + return new Markup(string.Concat(type, ": ", message)); + } + + private static Grid GetStackFrames(ExceptionInfo ex, ExceptionFormats format) + { + var grid = new Grid(); + grid.AddColumn(new GridColumn().PadLeft(2).PadRight(0).NoWrap()); + grid.AddColumn(new GridColumn().PadLeft(1).PadRight(0)); + + // Inner + if (ex.Inner != null) + { + grid.AddRow( + Text.Empty, + GetException(ex.Inner, format)); + } + + // Stack frames + foreach (var frame in ex.Frames) + { + var builder = new StringBuilder(); + + // Method + var shortenMethods = (format & ExceptionFormats.ShortenMethods) != 0; + builder.Append(Emphasize(frame.Method, new[] { '.' }, _methodColor.ToMarkupString(), shortenMethods)); + builder.Append('('); + builder.Append(string.Join(", ", frame.Parameters.Select(x => $"[{_parameterColor.ToMarkupString()}]{x.Type.SafeMarkup()}[/] {x.Name}"))); + builder.Append(')'); + + if (frame.Path != null) + { + builder.Append(" [").Append(_dimmedColor.ToMarkupString()).Append("]in[/] "); + + // Path + AppendPath(builder, frame, format); + + // Line number + if (frame.LineNumber != null) + { + builder.Append(':'); + builder.Append('[').Append(_parameterColor.ToMarkupString()).Append(']').Append(frame.LineNumber).Append("[/]"); + } + } + + grid.AddRow($"[{_dimmedColor.ToMarkupString()}]at[/]", builder.ToString()); + } + + return grid; + } + + private static void AppendPath(StringBuilder builder, StackFrameInfo frame, ExceptionFormats format) + { + if (frame?.Path is null) + { + return; + } + + void RenderLink() + { + var shortenPaths = (format & ExceptionFormats.ShortenPaths) != 0; + builder.Append(Emphasize(frame.Path, new[] { '/', '\\' }, $"b {_pathColor.ToMarkupString()}", shortenPaths)); + } + + if ((format & ExceptionFormats.ShowLinks) != 0) + { + var hasLink = frame.TryGetUri(out var uri); + if (hasLink && uri != null) + { + builder.Append("[link=").Append(uri.AbsoluteUri).Append(']'); + } + + RenderLink(); + + if (hasLink && uri != null) + { + builder.Append("[/]"); + } + } + else + { + RenderLink(); + } + } + + private static string Emphasize(string input, char[] separators, string color, bool compact) + { + var builder = new StringBuilder(); + + var type = input; + var index = type.LastIndexOfAny(separators); + if (index != -1) + { + if (!compact) + { + builder.Append("[silver]").Append(type, 0, index + 1).Append("[/]"); + } + + builder.Append('[').Append(color).Append(']').Append(type, index + 1, type.Length - index - 1).Append("[/]"); + } + else + { + builder.Append(type); + } + + return builder.ToString(); + } + } +} diff --git a/src/Spectre.Console/Internal/ExceptionInfo.cs b/src/Spectre.Console/Internal/ExceptionInfo.cs new file mode 100644 index 0000000..b8b6fc9 --- /dev/null +++ b/src/Spectre.Console/Internal/ExceptionInfo.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; + +namespace Spectre.Console.Internal +{ + internal sealed class ExceptionInfo + { + public string Type { get; } + public string Message { get; } + public List Frames { get; } + public ExceptionInfo? Inner { get; } + + public ExceptionInfo( + string type, string message, + List frames, + ExceptionInfo? inner) + { + Type = type ?? string.Empty; + Message = message ?? string.Empty; + Frames = frames ?? new List(); + Inner = inner; + } + } +} diff --git a/src/Spectre.Console/Internal/ExceptionParser.cs b/src/Spectre.Console/Internal/ExceptionParser.cs new file mode 100644 index 0000000..bf2e0fd --- /dev/null +++ b/src/Spectre.Console/Internal/ExceptionParser.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace Spectre.Console.Internal +{ + internal static class ExceptionParser + { + private static readonly Regex _messageRegex = new Regex(@"^(?'type'.*):\s(?'message'.*)$"); + private static readonly Regex _stackFrameRegex = new Regex(@"^\s*\w*\s(?'method'.*)\((?'params'.*)\)"); + private static readonly Regex _fullStackFrameRegex = new Regex(@"^\s*(?'at'\w*)\s(?'method'.*)\((?'params'.*)\)\s(?'in'\w*)\s(?'path'.*)\:(?'line'\w*)\s(?'linenumber'\d*)$"); + + public static ExceptionInfo? Parse(string exception) + { + if (exception is null) + { + throw new ArgumentNullException(nameof(exception)); + } + + var lines = exception.SplitLines(); + return Parse(new Queue(lines)); + } + + private static ExceptionInfo? Parse(Queue lines) + { + if (lines.Count == 0) + { + // Error: No lines to parse + return null; + } + + var line = lines.Dequeue(); + line = line.Replace(" ---> ", string.Empty); + + var match = _messageRegex.Match(line); + if (!match.Success) + { + return null; + } + + var inner = (ExceptionInfo?)null; + + // Stack frames + var frames = new List(); + while (lines.Count > 0) + { + if (lines.Peek().TrimStart().StartsWith("---> ", StringComparison.OrdinalIgnoreCase)) + { + inner = Parse(lines); + if (inner == null) + { + // Error: Could not parse inner exception + return null; + } + + continue; + } + + line = lines.Dequeue(); + + if (string.IsNullOrWhiteSpace(line)) + { + // Empty line + continue; + } + + if (line.TrimStart().StartsWith("--- ", StringComparison.OrdinalIgnoreCase)) + { + // End of inner exception + break; + } + + var stackFrame = ParseStackFrame(line); + if (stackFrame == null) + { + // Error: Could not parse stack frame + return null; + } + + frames.Add(stackFrame); + } + + return new ExceptionInfo( + match.Groups["type"].Value, + match.Groups["message"].Value, + frames, inner); + } + + private static StackFrameInfo? ParseStackFrame(string frame) + { + var match = _fullStackFrameRegex.Match(frame); + if (match?.Success != true) + { + match = _stackFrameRegex.Match(frame); + if (match?.Success != true) + { + return null; + } + } + + var parameters = ParseMethodParameters(match.Groups["params"].Value); + if (parameters == null) + { + // Error: Could not parse parameters + return null; + } + + var method = match.Groups["method"].Value; + var path = match.Groups["path"].Success ? match.Groups["path"].Value : null; + + var lineNumber = (int?)null; + if (!string.IsNullOrWhiteSpace(match.Groups["linenumber"].Value)) + { + lineNumber = int.Parse(match.Groups["linenumber"].Value, CultureInfo.InvariantCulture); + } + + return new StackFrameInfo(method, parameters, path, lineNumber); + } + + private static List<(string Type, string Name)>? ParseMethodParameters(string parameters) + { + var result = new List<(string Type, string Name)>(); + foreach (var parameterPart in parameters.Split(new[] { ", " }, StringSplitOptions.RemoveEmptyEntries)) + { + var parameterNameIndex = parameterPart.LastIndexOf(' '); + if (parameterNameIndex == -1) + { + // Error: Could not parse parameter + return null; + } + + var type = parameterPart.Substring(0, parameterNameIndex); + var name = parameterPart.Substring(parameterNameIndex + 1, parameterPart.Length - parameterNameIndex - 1); + + result.Add((type, name)); + } + + return result; + } + } +} diff --git a/src/Spectre.Console/Internal/StackFrameInfo.cs b/src/Spectre.Console/Internal/StackFrameInfo.cs new file mode 100644 index 0000000..34deacf --- /dev/null +++ b/src/Spectre.Console/Internal/StackFrameInfo.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Net; + +namespace Spectre.Console.Internal +{ + internal sealed class StackFrameInfo + { + public string Method { get; } + public List<(string Type, string Name)> Parameters { get; } + public string? Path { get; } + public int? LineNumber { get; } + + public StackFrameInfo( + string method, List<(string Type, string Name)> parameters, + string? path, int? lineNumber) + { + Method = method ?? throw new System.ArgumentNullException(nameof(method)); + Parameters = parameters ?? throw new System.ArgumentNullException(nameof(parameters)); + Path = path; + LineNumber = lineNumber; + } + + [SuppressMessage("Design", "CA1031:Do not catch general exception types")] + public bool TryGetUri([NotNullWhen(true)] out Uri? result) + { + try + { + if (Path == null) + { + result = null; + return false; + } + + if (!Uri.TryCreate(Path, UriKind.Absolute, out var uri)) + { + result = null; + return false; + } + + if (uri.Scheme == "file") + { + // For local files, we need to append + // the host name. Otherwise the terminal + // will most probably not allow it. + var builder = new UriBuilder(uri) + { + Host = Dns.GetHostName(), + }; + + uri = builder.Uri; + } + + result = uri; + return true; + } + catch + { + result = null; + return false; + } + } + } +} diff --git a/src/Spectre.Console/Rendering/Segment.cs b/src/Spectre.Console/Rendering/Segment.cs index 35c823d..58667ec 100644 --- a/src/Spectre.Console/Rendering/Segment.cs +++ b/src/Spectre.Console/Rendering/Segment.cs @@ -239,7 +239,7 @@ namespace Spectre.Console.Rendering } // Same style? - if (previous.Style.Equals(segment.Style)) + if (previous.Style.Equals(segment.Style) && !previous.IsLineBreak) { previous = new Segment(previous.Text + segment.Text, previous.Style); } @@ -299,7 +299,15 @@ namespace Spectre.Console.Rendering while (lengthLeft > 0) { var index = totalLength - lengthLeft; + + // How many characters should we take? var take = Math.Min(width, totalLength - index); + if (take == 0) + { + // This shouldn't really occur, but I don't like + // never ending loops if it does... + throw new InvalidOperationException("Text folding failed since 'take' was zero."); + } result.Add(new Segment(segment.Text.Substring(index, take), segment.Style)); lengthLeft -= take; diff --git a/src/Spectre.Console/TableBorder.cs b/src/Spectre.Console/TableBorder.cs index 29f9202..7dd937f 100644 --- a/src/Spectre.Console/TableBorder.cs +++ b/src/Spectre.Console/TableBorder.cs @@ -1,7 +1,5 @@ using System; using System.Collections.Generic; -using System.Globalization; -using System.Linq; using System.Text; using Spectre.Console.Internal; using Spectre.Console.Rendering; diff --git a/src/Spectre.Console/Widgets/Panel.cs b/src/Spectre.Console/Widgets/Panel.cs index 7eb39c8..e1bc285 100644 --- a/src/Spectre.Console/Widgets/Panel.cs +++ b/src/Spectre.Console/Widgets/Panel.cs @@ -94,8 +94,15 @@ namespace Spectre.Console // Split the child segments into lines. var childSegments = ((IRenderable)child).Render(context, childWidth); - foreach (var line in Segment.SplitLines(childSegments, panelWidth)) + foreach (var line in Segment.SplitLines(childSegments, childWidth)) { + if (line.Count == 1 && line[0].IsWhiteSpace) + { + // NOTE: This check might impact other things. + // Hopefully not, but there is a chance. + continue; + } + result.Add(new Segment(border.GetPart(BoxBorderPart.Left), borderStyle)); var content = new List(); diff --git a/src/Spectre.Console/Widgets/Paragraph.cs b/src/Spectre.Console/Widgets/Paragraph.cs index 4fdc1f5..de4bbf9 100644 --- a/src/Spectre.Console/Widgets/Paragraph.cs +++ b/src/Spectre.Console/Widgets/Paragraph.cs @@ -227,17 +227,10 @@ namespace Spectre.Console throw new InvalidOperationException("Iterator returned empty segment."); } - if (newLine && current.IsWhiteSpace && !current.IsLineBreak) - { - newLine = false; - continue; - } - newLine = false; if (current.IsLineBreak) { - line.Add(current); lines.Add(line); line = new SegmentLine(); newLine = true; diff --git a/src/Spectre.Console/Widgets/Rows.cs b/src/Spectre.Console/Widgets/Rows.cs index 2a8455e..6de5c6d 100644 --- a/src/Spectre.Console/Widgets/Rows.cs +++ b/src/Spectre.Console/Widgets/Rows.cs @@ -44,22 +44,26 @@ namespace Spectre.Console /// protected override IEnumerable Render(RenderContext context, int maxWidth) { + var result = new List(); + foreach (var child in _children) { var segments = child.Render(context, maxWidth); foreach (var (_, _, last, segment) in segments.Enumerate()) { - yield return segment; + result.Add(segment); if (last) { if (!segment.IsLineBreak) { - yield return Segment.LineBreak; + result.Add(Segment.LineBreak); } } } } + + return result; } } } diff --git a/src/Spectre.Console/Widgets/Table.cs b/src/Spectre.Console/Widgets/Table.cs index f6d723d..673e4fe 100644 --- a/src/Spectre.Console/Widgets/Table.cs +++ b/src/Spectre.Console/Widgets/Table.cs @@ -298,7 +298,6 @@ namespace Spectre.Console var widths = width_ranges.Select(range => range.Max).ToList(); var tableWidth = widths.Sum(); - if (tableWidth > maxWidth) { var wrappable = _columns.Select(c => !c.NoWrap).ToList();