Add support for rendering exceptions

This commit is contained in:
Patrik Svensson
2020-10-03 02:08:31 +02:00
committed by Patrik Svensson
parent 971f9032ba
commit 3c3afe7439
35 changed files with 926 additions and 41 deletions

View File

@ -0,0 +1,20 @@
using System;
namespace Spectre.Console
{
/// <summary>
/// A console capable of writing ANSI escape sequences.
/// </summary>
public static partial class AnsiConsole
{
/// <summary>
/// Writes an exception to the console.
/// </summary>
/// <param name="exception">The exception to write to the console.</param>
/// <param name="format">The exception format options.</param>
public static void WriteException(Exception exception, ExceptionFormats format = ExceptionFormats.None)
{
Console.WriteException(exception, format);
}
}
}

View File

@ -1,7 +1,3 @@
using System;
using System.IO;
using Spectre.Console.Internal;
namespace Spectre.Console
{
/// <summary>
@ -9,9 +5,6 @@ namespace Spectre.Console
/// </summary>
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;
}
}
/// <summary>
/// Resets colors and text decorations.
/// </summary>
@ -78,8 +57,7 @@ namespace Spectre.Console
/// </summary>
public static void ResetColors()
{
Foreground = _defaultForeground;
Background = _defaultBackground;
CurrentStyle = Style.Plain;
}
}
}

View File

@ -16,7 +16,6 @@ namespace Spectre.Console
ColorSystem = ColorSystemSupport.Detect,
Out = System.Console.Out,
});
Initialize(System.Console.Out);
Created = true;
return console;
});

View File

@ -245,9 +245,32 @@ namespace Spectre.Console
};
}
/// <summary>
/// Converts the color to a markup string.
/// </summary>
/// <returns>A <see cref="string"/> representing the color as markup.</returns>
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);
}
/// <inheritdoc/>
public override string ToString()
{
if (IsDefault)
{
return "default";
}
if (Number != null)
{
var name = ColorTable.GetName(Number.Value);

View File

@ -16,7 +16,17 @@ namespace Spectre.Console
/// <returns>A string with emoji codes replaced with actual emoji.</returns>
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);
}
}

View File

@ -0,0 +1,41 @@
using System;
namespace Spectre.Console
{
/// <summary>
/// Represents how an exception is formatted.
/// </summary>
[Flags]
public enum ExceptionFormats
{
/// <summary>
/// The default formatting.
/// </summary>
None = 0,
/// <summary>
/// Whether or not paths should be shortened.
/// </summary>
ShortenPaths = 1,
/// <summary>
/// Whether or not types should be shortened.
/// </summary>
ShortenTypes = 2,
/// <summary>
/// Whether or not methods should be shortened.
/// </summary>
ShortenMethods = 4,
/// <summary>
/// Whether or not to show paths as links in the terminal.
/// </summary>
ShowLinks = 8,
/// <summary>
/// Shortens everything that can be shortened.
/// </summary>
ShortenEverything = ShortenMethods | ShortenTypes | ShortenPaths,
}
}

View File

@ -0,0 +1,21 @@
using System;
namespace Spectre.Console
{
/// <summary>
/// Contains extension methods for <see cref="IAnsiConsole"/>.
/// </summary>
public static partial class AnsiConsoleExtensions
{
/// <summary>
/// Writes an exception to the console.
/// </summary>
/// <param name="console">The console.</param>
/// <param name="exception">The exception to write to the console.</param>
/// <param name="format">The exception format options.</param>
public static void WriteException(this IAnsiConsole console, Exception exception, ExceptionFormats format = ExceptionFormats.None)
{
Render(console, exception.GetRenderable(format));
}
}
}

View File

@ -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))

View File

@ -0,0 +1,22 @@
using System;
using Spectre.Console.Rendering;
namespace Spectre.Console
{
/// <summary>
/// Contains extension methods for <see cref="Exception"/>.
/// </summary>
public static class ExceptionExtensions
{
/// <summary>
/// Gets a <see cref="IRenderable"/> representation of the exception.
/// </summary>
/// <param name="exception">The exception to format.</param>
/// <param name="format">The exception format options.</param>
/// <returns>A <see cref="IRenderable"/> representing the exception.</returns>
public static IRenderable GetRenderable(this Exception exception, ExceptionFormats format = ExceptionFormats.None)
{
return ExceptionFormatter.Format(exception, format);
}
}
}

View File

@ -0,0 +1,26 @@
namespace Spectre.Console
{
/// <summary>
/// Contains extension methods for <see cref="string"/>.
/// </summary>
public static class StringExtensions
{
/// <summary>
/// Converts the string to something that is safe to
/// use in a markup string.
/// </summary>
/// <param name="text">The text to convert.</param>
/// <returns>A string that is safe to use in a markup string.</returns>
public static string SafeMarkup(this string text)
{
if (text == null)
{
return string.Empty;
}
return text
.Replace("[", "[[")
.Replace("]", "]]");
}
}
}

View File

@ -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();
}
}
}

View File

@ -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<StackFrameInfo> Frames { get; }
public ExceptionInfo? Inner { get; }
public ExceptionInfo(
string type, string message,
List<StackFrameInfo> frames,
ExceptionInfo? inner)
{
Type = type ?? string.Empty;
Message = message ?? string.Empty;
Frames = frames ?? new List<StackFrameInfo>();
Inner = inner;
}
}
}

View File

@ -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<string>(lines));
}
private static ExceptionInfo? Parse(Queue<string> 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<StackFrameInfo>();
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;
}
}
}

View File

@ -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;
}
}
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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<Segment>();

View File

@ -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;

View File

@ -44,22 +44,26 @@ namespace Spectre.Console
/// <inheritdoc/>
protected override IEnumerable<Segment> Render(RenderContext context, int maxWidth)
{
var result = new List<Segment>();
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;
}
}
}

View File

@ -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();