From 1d8154f9b036f425674ab9dfc88925a29f6b3688 Mon Sep 17 00:00:00 2001 From: Phil Scott Date: Tue, 22 Mar 2022 17:34:25 -0400 Subject: [PATCH] Introduce MarkupInterpolated and MarkupLineInterpolated extensions (#761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Introduce MarkupInterpolated and MarkupLineInterpolated extensions These new methods enable easily writing markup with a nice and intuitive syntax without having to worry about escaping the markup. ```csharp string input = args[0]; string output = Process(input); AnsiConsole.MarkupLineInterpolated($"[blue]{input}[/] -> [green]{output}[/]"); ``` The `Interpolated` suffix was inspired by the Entity Framework Core [FromSqlInterpolated][1] method. [1]: https://docs.microsoft.com/en-us/ef/core/querying/raw-sql#passing-parameters * Fixing whitespace to match file scoped namespaces * Adding FromInterpolated helper to Markup widget Allows automatic handling of interpolated strings to be used on the Markup widget. This would be helpful for people working with Tables and the Tree control who would not be using the MarkupInterpolated methods. * Documentation for markup interpolated methods. Co-authored-by: Cédric Luthi --- docs/input/best-practices.md | 4 + docs/input/markup.md | 9 +++ src/Spectre.Console/AnsiConsole.Markup.cs | 74 ++++++++++++++++++ .../AnsiConsoleExtensions.Markup.cs | 78 +++++++++++++++++++ src/Spectre.Console/Widgets/Markup.cs | 29 +++++++ .../Unit/Widgets/MarkupTests.cs | 40 ++++++++++ 6 files changed, 234 insertions(+) diff --git a/docs/input/best-practices.md b/docs/input/best-practices.md index 17b8403..7d7c13c 100644 --- a/docs/input/best-practices.md +++ b/docs/input/best-practices.md @@ -60,6 +60,10 @@ Spectre.Console will tell your terminal to use the color that is configured in t If you are using an 8 or 24-bit color for the foreground text, it is recommended that you also set an appropriate background color to match. +**Do** escape data when outputting any user input or any external data via Markup using the [`EscapeMarkup`](xref:M:Spectre.Console.Markup.Escape(System.String)) method on the data. Any user input containing `[` or `]` will likely cause a runtime error while rendering otherwise. + +**Consider** replacing `Markup` and `MarkupLine` with [`MarkupInterpolated`](xref:M:Spectre.Console.AnsiConsole.MarkupInterpolated(System.FormattableString)) and [`MarkupLineInterpolated`](xref:M:Spectre.Console.AnsiConsole.MarkupLineInterpolated(System.FormattableString)). Both these methods will automatically escape all data in the interpolated string holes. When working with widgets such as the Table and Tree, consider using [`Markup.FromInterpolated`](xref:M:Spectre.Console.Markup.FromInterpolated(System.FormattableString,Spectre.Console.Style)) to generate an `IRenderable` from an interpolated string. + ### Live-Rendering Best Practices Spectre.Console has a variety of [live-rendering capabilities](live) widgets. These widgets can be used to display data diff --git a/docs/input/markup.md b/docs/input/markup.md index 3632605..a7b3e4b 100644 --- a/docs/input/markup.md +++ b/docs/input/markup.md @@ -63,6 +63,15 @@ You can also use the `Markup.Escape` method. ```csharp AnsiConsole.Markup("[red]{0}[/]", Markup.Escape("Hello [World]")); ``` + +## Escaping Interpolated Strings + +When working with interpolated string, you can use the `MarkupInterpolated` and `MarkupInterpolatedLine` methods to automatically escape the values in the interpolated string holes. + +```csharp +AnsiConsole.MarkupInterpolated("[red]{0}[/]", "Hello [World]"); +``` + ## Setting background color You can set the background color in markup by prefixing the color with diff --git a/src/Spectre.Console/AnsiConsole.Markup.cs b/src/Spectre.Console/AnsiConsole.Markup.cs index 87b35ca..b9d47f0 100644 --- a/src/Spectre.Console/AnsiConsole.Markup.cs +++ b/src/Spectre.Console/AnsiConsole.Markup.cs @@ -24,6 +24,24 @@ public static partial class AnsiConsole Console.Markup(format, args); } + /// + /// Writes the specified markup to the console. + /// + /// All interpolation holes which contain a string are automatically escaped so you must not call . + /// + /// + /// + /// string input = args[0]; + /// string output = Process(input); + /// AnsiConsole.MarkupInterpolated($"[blue]{input}[/] -> [green]{output}[/]"); + /// + /// + /// The interpolated string value to write. + public static void MarkupInterpolated(FormattableString value) + { + Console.MarkupInterpolated(value); + } + /// /// Writes the specified markup to the console. /// @@ -35,6 +53,25 @@ public static partial class AnsiConsole Console.Markup(provider, format, args); } + /// + /// Writes the specified markup to the console. + /// + /// All interpolation holes which contain a string are automatically escaped so you must not call . + /// + /// + /// + /// string input = args[0]; + /// string output = Process(input); + /// AnsiConsole.MarkupInterpolated(CultureInfo.InvariantCulture, $"[blue]{input}[/] -> [green]{output}[/]"); + /// + /// + /// An object that supplies culture-specific formatting information. + /// The interpolated string value to write. + public static void MarkupInterpolated(IFormatProvider provider, FormattableString value) + { + Console.MarkupInterpolated(provider, value); + } + /// /// Writes the specified markup, followed by the current line terminator, to the console. /// @@ -54,6 +91,24 @@ public static partial class AnsiConsole Console.MarkupLine(format, args); } + /// + /// Writes the specified markup, followed by the current line terminator, to the console. + /// + /// All interpolation holes which contain a string are automatically escaped so you must not call . + /// + /// + /// + /// string input = args[0]; + /// string output = Process(input); + /// AnsiConsole.MarkupLineInterpolated($"[blue]{input}[/] -> [green]{output}[/]"); + /// + /// + /// The interpolated string value to write. + public static void MarkupLineInterpolated(FormattableString value) + { + Console.MarkupLineInterpolated(value); + } + /// /// Writes the specified markup, followed by the current line terminator, to the console. /// @@ -64,4 +119,23 @@ public static partial class AnsiConsole { Console.MarkupLine(provider, format, args); } + + /// + /// Writes the specified markup, followed by the current line terminator, to the console. + /// + /// All interpolation holes which contain a string are automatically escaped so you must not call . + /// + /// + /// + /// string input = args[0]; + /// string output = Process(input); + /// AnsiConsole.MarkupLineInterpolated(CultureInfo.InvariantCulture, $"[blue]{input}[/] -> [green]{output}[/]"); + /// + /// + /// An object that supplies culture-specific formatting information. + /// The interpolated string value to write. + public static void MarkupLineInterpolated(IFormatProvider provider, FormattableString value) + { + Console.MarkupLineInterpolated(provider, value); + } } \ No newline at end of file diff --git a/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Markup.cs b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Markup.cs index b6d8139..dc5023d 100644 --- a/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Markup.cs +++ b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Markup.cs @@ -16,6 +16,25 @@ public static partial class AnsiConsoleExtensions Markup(console, CultureInfo.CurrentCulture, format, args); } + /// + /// Writes the specified markup to the console. + /// + /// All interpolation holes which contain a string are automatically escaped so you must not call . + /// + /// + /// + /// string input = args[0]; + /// string output = Process(input); + /// console.MarkupInterpolated($"[blue]{input}[/] -> [green]{output}[/]"); + /// + /// + /// The console to write to. + /// The interpolated string value to write. + public static void MarkupInterpolated(this IAnsiConsole console, FormattableString value) + { + MarkupInterpolated(console, CultureInfo.CurrentCulture, value); + } + /// /// Writes the specified markup to the console. /// @@ -28,6 +47,26 @@ public static partial class AnsiConsoleExtensions Markup(console, string.Format(provider, format, args)); } + /// + /// Writes the specified markup to the console. + /// + /// All interpolation holes which contain a string are automatically escaped so you must not call . + /// + /// + /// + /// string input = args[0]; + /// string output = Process(input); + /// console.MarkupInterpolated(CultureInfo.InvariantCulture, $"[blue]{input}[/] -> [green]{output}[/]"); + /// + /// + /// The console to write to. + /// An object that supplies culture-specific formatting information. + /// The interpolated string value to write. + public static void MarkupInterpolated(this IAnsiConsole console, IFormatProvider provider, FormattableString value) + { + Markup(console, Console.Markup.EscapeInterpolated(provider, value)); + } + /// /// Writes the specified markup to the console. /// @@ -49,6 +88,25 @@ public static partial class AnsiConsoleExtensions MarkupLine(console, CultureInfo.CurrentCulture, format, args); } + /// + /// Writes the specified markup, followed by the current line terminator, to the console. + /// + /// All interpolation holes which contain a string are automatically escaped so you must not call . + /// + /// + /// + /// string input = args[0]; + /// string output = Process(input); + /// console.MarkupLineInterpolated($"[blue]{input}[/] -> [green]{output}[/]"); + /// + /// + /// The console to write to. + /// The interpolated string value to write. + public static void MarkupLineInterpolated(this IAnsiConsole console, FormattableString value) + { + MarkupLineInterpolated(console, CultureInfo.CurrentCulture, value); + } + /// /// Writes the specified markup, followed by the current line terminator, to the console. /// @@ -70,4 +128,24 @@ public static partial class AnsiConsoleExtensions { Markup(console, provider, format + Environment.NewLine, args); } + + /// + /// Writes the specified markup, followed by the current line terminator, to the console. + /// + /// All interpolation holes which contain a string are automatically escaped so you must not call . + /// + /// + /// + /// string input = args[0]; + /// string output = Process(input); + /// console.MarkupLineInterpolated(CultureInfo.InvariantCulture, $"[blue]{input}[/] -> [green]{output}[/]"); + /// + /// + /// The console to write to. + /// An object that supplies culture-specific formatting information. + /// The interpolated string value to write. + public static void MarkupLineInterpolated(this IAnsiConsole console, IFormatProvider provider, FormattableString value) + { + MarkupLine(console, Console.Markup.EscapeInterpolated(provider, value)); + } } \ No newline at end of file diff --git a/src/Spectre.Console/Widgets/Markup.cs b/src/Spectre.Console/Widgets/Markup.cs index e598a1b..7b23376 100644 --- a/src/Spectre.Console/Widgets/Markup.cs +++ b/src/Spectre.Console/Widgets/Markup.cs @@ -54,6 +54,29 @@ public sealed class Markup : Renderable, IAlignable, IOverflowable return ((IRenderable)_paragraph).Render(context, maxWidth); } + /// + /// Returns a new instance of a Markup widget from an interpolated string. + /// + /// The interpolated string value to write. + /// The style of the text. + /// A new markup instance. + public static Markup FromInterpolated(FormattableString value, Style? style = null) + { + return FromInterpolated(CultureInfo.CurrentCulture, value, style); + } + + /// + /// Returns a new instance of a Markup widget from an interpolated string. + /// + /// The format provider to use. + /// The interpolated string value to write. + /// The style of the text. + /// A new markup instance. + public static Markup FromInterpolated(IFormatProvider provider, FormattableString value, Style? style = null) + { + return new Markup(EscapeInterpolated(provider, value), style); + } + /// /// Escapes text so that it won’t be interpreted as markup. /// @@ -83,4 +106,10 @@ public sealed class Markup : Renderable, IAlignable, IOverflowable return text.RemoveMarkup(); } + + internal static string EscapeInterpolated(IFormatProvider provider, FormattableString value) + { + object?[] args = value.GetArguments().Select(arg => arg is string s ? s.EscapeMarkup() : arg).ToArray(); + return string.Format(provider, value.Format, args); + } } \ No newline at end of file diff --git a/test/Spectre.Console.Tests/Unit/Widgets/MarkupTests.cs b/test/Spectre.Console.Tests/Unit/Widgets/MarkupTests.cs index fc9220b..aad415c 100644 --- a/test/Spectre.Console.Tests/Unit/Widgets/MarkupTests.cs +++ b/test/Spectre.Console.Tests/Unit/Widgets/MarkupTests.cs @@ -72,6 +72,25 @@ public sealed class MarkupTests // Then result.ShouldBe(expected); } + + [Theory] + [InlineData("Hello", "World", "\x1B[38;5;11mHello\x1B[0m \x1B[38;5;9mWorld\x1B[0m 2021-02-03")] + [InlineData("Hello", "World [", "\x1B[38;5;11mHello\x1B[0m \x1B[38;5;9mWorld [\x1B[0m 2021-02-03")] + [InlineData("Hello", "World ]", "\x1B[38;5;11mHello\x1B[0m \x1B[38;5;9mWorld ]\x1B[0m 2021-02-03")] + [InlineData("[Hello]", "World", "\x1B[38;5;11m[Hello]\x1B[0m \x1B[38;5;9mWorld\x1B[0m 2021-02-03")] + [InlineData("[[Hello]]", "[World]", "\x1B[38;5;11m[[Hello]]\x1B[0m \x1B[38;5;9m[World]\x1B[0m 2021-02-03")] + public void Should_Escape_Markup_When_Using_MarkupInterpolated(string input1, string input2, string expected) + { + // Given + var console = new TestConsole().EmitAnsiSequences(); + var date = new DateTime(2021, 2, 3); + + // When + console.MarkupInterpolated($"[yellow]{input1}[/] [red]{input2}[/] {date:yyyy-MM-dd}"); + + // Then + console.Output.ShouldBe(expected); + } } [Theory] @@ -134,4 +153,25 @@ public sealed class MarkupTests console.Output.NormalizeLineEndings() .ShouldBe("{\n"); } + + [Fact] + public void Can_Use_Interpolated_Markup_As_IRenderable() + { + // Given + var console = new TestConsole(); + const string Num = "[value["; + var table = new Table().AddColumns("First Column"); + table.AddRow(Markup.FromInterpolated($"Result: {Num}")); + + // When + console.Write(table); + + // Then + console.Output.NormalizeLineEndings().ShouldBe(@"┌─────────────────┐ +│ First Column │ +├─────────────────┤ +│ Result: [value[ │ +└─────────────────┘ +".NormalizeLineEndings()); + } }