diff --git a/docs/output-system.md b/docs/output-system.md index c267efd4..07a9adb1 100644 --- a/docs/output-system.md +++ b/docs/output-system.md @@ -23,6 +23,37 @@ The active output format is resolved in this order: | `yaml` | YAML serialization. | | `markdown` | Markdown table/document rendering. | +### JSON results + +A handler can return `System.Text.Json.Nodes.JsonNode` (`JsonObject`, `JsonArray`, `JsonValue`) or a +`JsonElement`, including as rows of an `IReplPageSource`. `human` and `spectre` then render the JSON +data rather than the CLR members of those types: + +- An object becomes one `key: value` line per field. +- In `spectre` as in `human`, a table's header labels stay on one line: a label wider than its column is + truncated rather than wrapped, and a line break in a label reads as a space. +- Rows that are all objects with at least one key become a table. Its columns are the union of the rows' + keys in first-seen order, matched ordinally, and a key missing from a row leaves that cell empty. The rows' + keys, not a type, define the columns, so each page the pager fetches can name other columns than the page + before it. See [Result Flow And Paging](result-flow.md) for how the pager shows their headers. +- An array of scalars becomes one value per line. So does a page of rows among which one is a JSON null or + an empty object: each row reads as its literal, since a blank table row would read as no row at all. +- A page declared with a JSON item type, such as `IReplPageSource`, renders as JSON even when + every item on it is a JSON null. +- Values are compact JSON literals. So a string shows as `"x"`, an explicit JSON null shows as `null`, and + a nested object or array shows as itself. A `JsonElement` object that repeats a property name shows the + last value given for it, as JavaScript reads it. +- A JSON value held by a property of an ordinary result object shows as a compact literal too, and a + property declared as JSON (`JsonNode?`, `JsonElement?`) that holds `null` reads `null`, unless its + `DisplayFormat` sets a `NullDisplayText`. A JSON value passed as a result's details renders like a JSON + result. + +In JSON strings and keys, control characters and Unicode format characters (such as bidirectional +overrides) are escaped. So a payload can neither drive the terminal nor make it display something other +than the data. Non-ASCII text stays readable. This covers JSON values only: a plain CLR `string` in a +result is still written as-is. The `json` format and MCP output are unchanged, and `markdown` does not +special-case JSON yet. + ### Format aliases The built-in aliases are: diff --git a/docs/result-flow.md b/docs/result-flow.md index 7a3c4ab8..d183ec24 100644 --- a/docs/result-flow.md +++ b/docs/result-flow.md @@ -523,6 +523,28 @@ The full viewport is inspired by `less`: it does not depend on terminal scrollback. It renders from an internal buffer and fetches additional `IReplPageSource` payloads as the user pages past the buffered end. +The pager pins the first page's table header. `human` and `spectre` render a +table's header on every page and tell the pager exactly which lines hold the +header and which columns it names, whether the pager shows a result that is +simply long or pages an `IReplPageSource`. A long result keeps its footer +asking to rerun for the next page, since that pager cannot fetch it. A fetched page whose header names +the columns the page before it showed adds only its rows. A page naming other +columns keeps its own header, since its rows would otherwise sit under headings +that are not theirs. That can happen with JSON rows, whose columns come from +their keys, or with pages of rows of different types. In `inline` and `full`, +the header at the top of the viewport stays the first page's, and a later +header scrolls with its rows. + +A custom `IReplPagerRenderer` receives each fetched page as text: a header +naming the previous page's columns is stripped from it first, and one naming +other columns is kept. + +For any other transformer, the pager recognizes a header from the text: a +separator line under the first line, a first line starting with `#` and a +space, or bold styling. It drops that header, and any line that reads like it, +from every fetched page, and drops lines that read like the rerun footer from +the first payload. + Applications that need a different terminal experience can register a custom `IReplPagerRenderer` with `options.Output.ResultFlow.UsePagerRenderer(renderer)`. A custom renderer is diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index dfcd7bf8..ef55b3cf 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -1286,11 +1286,11 @@ internal async ValueTask RenderOutputAsync( .ConfigureAwait(false); } - var payload = await transformer.TransformAsync(result, cancellationToken).ConfigureAwait(false); - payload = TryColorizeStructuredPayload(payload, format, isInteractive); + var (rendered, layout) = await RenderPayloadAsync(transformer, result, cancellationToken).ConfigureAwait(false); + var payload = TryColorizeStructuredPayload(rendered, format, isInteractive); if (!string.IsNullOrEmpty(payload)) { - await WritePayloadAsync(payload, transformer, resultFlow, cancellationToken).ConfigureAwait(false); + await WritePayloadAsync(payload, layout, transformer, resultFlow, cancellationToken).ConfigureAwait(false); } return true; @@ -1305,11 +1305,12 @@ private async ValueTask RenderPageSourceAsync( { var request = CreatePageSourceRequest(resultFlow); var page = await FetchPageSourceAsync(source, request, cancellationToken).ConfigureAwait(false); - var payload = await transformer.TransformAsync(page, cancellationToken).ConfigureAwait(false); - payload = TryColorizeStructuredPayload(payload, transformer.Name, isInteractive); + var (rendered, layout) = await RenderPayloadAsync(transformer, page, cancellationToken).ConfigureAwait(false); + var payload = TryColorizeStructuredPayload(rendered, transformer.Name, isInteractive); if (!TryCreatePager( payload, + layout, transformer, resultFlow, page.PageInfo.HasMore, @@ -1358,9 +1359,13 @@ private async ValueTask RenderPageSourcePagerAsync( CancellationToken cancellationToken) { var nextCursor = page.PageInfo.NextCursor; - var pagerPayload = await TransformPagerPageAsync(transformer, page, ResultFlowPageRenderMode.Initial, cancellationToken) + var (initialPayload, initialLayout) = await RenderPagerPageAsync( + transformer, + page, + ResultFlowPageRenderMode.Initial, + cancellationToken) .ConfigureAwait(false); - pagerPayload = TryColorizeStructuredPayload(pagerPayload, transformer.Name, isInteractive); + var pagerPayload = TryColorizeStructuredPayload(initialPayload, transformer.Name, isInteractive); await ResultFlowPager.WriteAsync( pagerPayload, ReplSessionIO.Output, @@ -1372,6 +1377,7 @@ await ResultFlowPager.WriteAsync( PagerMode = pagerMode, AnsiEnabled = ansiEnabled, HasMorePayload = page.PageInfo.HasMore, + PayloadLayout = initialLayout, FetchNextPayload = FetchNextPayloadAsync, PagerRenderers = _options.Output.ResultFlow.PagerRenderers, MaxBufferedLines = _options.Output.ResultFlow.MaxBufferedLines, @@ -1390,13 +1396,17 @@ await ResultFlowPager.WriteAsync( var nextRequest = request with { Cursor = nextCursor }; var nextPage = await FetchPageSourceAsync(source, nextRequest, token).ConfigureAwait(false); nextCursor = nextPage.PageInfo.NextCursor; - var nextPayload = await TransformPagerPageAsync(transformer, nextPage, ResultFlowPageRenderMode.Continuation, token) + var (nextPayload, nextLayout) = await RenderPagerPageAsync( + transformer, + nextPage, + ResultFlowPageRenderMode.Continuation, + token) .ConfigureAwait(false); - nextPayload = TryColorizeStructuredPayload(nextPayload, transformer.Name, isInteractive); return new ResultFlowPagerPage( - nextPayload, + TryColorizeStructuredPayload(nextPayload, transformer.Name, isInteractive), nextPage.PageInfo.HasMore, - ContainsPresentationChrome: false); + ContainsPresentationChrome: false, + nextLayout); } } @@ -1412,26 +1422,53 @@ await ResultFlowPager.WriteAsync( return await RefuseGlobalOptionErrorsAsync(globalOptions, cancellationToken).ConfigureAwait(false); } - private static ValueTask TransformPagerPageAsync( + // A transformer that declares its layout says where its header and footer are; for any other, the pager + // detects them from the text. + private static async ValueTask<(string Payload, RenderedLayout? Layout)> RenderPayloadAsync( + IOutputTransformer transformer, + object? value, + CancellationToken cancellationToken) + { + if (transformer is ILayoutDeclaringOutputTransformer declaring) + { + var rendered = await declaring.RenderAsync(value, cancellationToken).ConfigureAwait(false); + return (rendered.Text, rendered.Layout); + } + + return (await transformer.TransformAsync(value, cancellationToken).ConfigureAwait(false), null); + } + + // A transformer built against 0.11 still gets the render mode that asks it to leave a continuation's header + // out, which the pager then detects on the first page only. + private static async ValueTask<(string Payload, RenderedLayout? Layout)> RenderPagerPageAsync( IOutputTransformer transformer, IReplPage page, ResultFlowPageRenderMode mode, CancellationToken cancellationToken) { var displayPage = CreatePagerDisplayPage(page); - return transformer is IResultFlowOutputTransformer resultFlowTransformer - ? resultFlowTransformer.TransformPageAsync(displayPage, mode, cancellationToken) - : transformer.TransformAsync(displayPage, cancellationToken); + if (transformer is ILayoutDeclaringOutputTransformer declaring) + { + var rendered = await declaring.RenderPageAsync(displayPage, cancellationToken).ConfigureAwait(false); + return (rendered.Text, rendered.Layout); + } + + var text = transformer is IResultFlowOutputTransformer resultFlowTransformer + ? await resultFlowTransformer.TransformPageAsync(displayPage, mode, cancellationToken).ConfigureAwait(false) + : await transformer.TransformAsync(displayPage, cancellationToken).ConfigureAwait(false); + return (text, null); } private async ValueTask WritePayloadAsync( string payload, + RenderedLayout? layout, IOutputTransformer transformer, ResultFlowInvocationOptions? resultFlow, CancellationToken cancellationToken) { if (TryCreatePager( payload, + layout?.WithFooter(0), transformer, resultFlow, out var keyReader, @@ -1448,6 +1485,9 @@ await ResultFlowPager.WriteAsync( VisibleRows = visibleRows, PagerMode = pagerMode, AnsiEnabled = ansiEnabled, + // This pager cannot fetch more, so a footer asking to rerun for the next page is the only way + // to continue, and stays in view instead of being stripped. + PayloadLayout = layout?.WithFooter(0), PagerRenderers = _options.Output.ResultFlow.PagerRenderers, MaxBufferedLines = _options.Output.ResultFlow.MaxBufferedLines, }, @@ -1461,6 +1501,7 @@ await ResultFlowPager.WriteAsync( private bool TryCreatePager( string payload, + RenderedLayout? layout, IOutputTransformer transformer, ResultFlowInvocationOptions? resultFlow, [NotNullWhen(true)] out IReplKeyReader? keyReader, @@ -1469,6 +1510,7 @@ private bool TryCreatePager( out bool ansiEnabled) => TryCreatePager( payload, + layout, transformer, resultFlow, hasMorePayload: false, @@ -1479,6 +1521,7 @@ private bool TryCreatePager( private bool TryCreatePager( string payload, + RenderedLayout? layout, IOutputTransformer transformer, ResultFlowInvocationOptions? resultFlow, bool hasMorePayload, @@ -1501,7 +1544,7 @@ private bool TryCreatePager( } if (!TryResolvePagerVisibleRows(out visibleRows) - || (!hasMorePayload && ResultFlowPager.CountLines(payload) <= visibleRows) + || (!hasMorePayload && ResultFlowPager.CountLines(payload, layout) <= visibleRows) || !TryResolvePagerKeyReader(out keyReader)) { return false; diff --git a/src/Repl.Core/ILayoutDeclaringOutputTransformer.cs b/src/Repl.Core/ILayoutDeclaringOutputTransformer.cs new file mode 100644 index 00000000..26385837 --- /dev/null +++ b/src/Repl.Core/ILayoutDeclaringOutputTransformer.cs @@ -0,0 +1,21 @@ +namespace Repl; + +/// +/// A human output transformer that declares the layout of what it renders, so the pager can pin its header, drop +/// that header's repeats and strip its footer without inferring any of them from the text. +/// +/// +/// A table always renders its header, on every page: a page of JSON rows takes its columns from its own keys, so it +/// can name other columns than the page before it. The pager drops a header that repeats the previous page's +/// columns and keeps one that names others. This is a separate interface rather than new members of +/// : a friend assembly built against an earlier Repl.Core implements that +/// one as it shipped, and still has to load when only Repl.Core is upgraded. +/// +internal interface ILayoutDeclaringOutputTransformer : IOutputTransformer +{ + /// Renders as does, with its layout. + ValueTask RenderAsync(object? value, CancellationToken cancellationToken = default); + + /// Renders a page fetched for the pager: its items, without the footer that asks to rerun for more. + ValueTask RenderPageAsync(IReplPage page, CancellationToken cancellationToken = default); +} diff --git a/src/Repl.Core/IResultFlowOutputTransformer.cs b/src/Repl.Core/IResultFlowOutputTransformer.cs index 093d790d..855cb17c 100644 --- a/src/Repl.Core/IResultFlowOutputTransformer.cs +++ b/src/Repl.Core/IResultFlowOutputTransformer.cs @@ -1,5 +1,8 @@ namespace Repl; +// Kept as it shipped in 0.11, with ResultFlowPageRenderMode: a friend assembly built against it, such as an older +// Repl.Spectre, implements this exact signature and must still load when only Repl.Core is upgraded. Transformers +// built with this version declare their layout through ILayoutDeclaringOutputTransformer instead. internal interface IResultFlowOutputTransformer : IOutputTransformer { ValueTask TransformPageAsync( diff --git a/src/Repl.Core/InternalsVisibleTo.cs b/src/Repl.Core/InternalsVisibleTo.cs index 92e02d1d..446b01ca 100644 --- a/src/Repl.Core/InternalsVisibleTo.cs +++ b/src/Repl.Core/InternalsVisibleTo.cs @@ -8,3 +8,4 @@ [assembly: InternalsVisibleTo("Repl.Spectre")] [assembly: InternalsVisibleTo("Repl.Mcp")] [assembly: InternalsVisibleTo("Repl.McpTests")] +[assembly: InternalsVisibleTo("Repl.SpectreTests")] diff --git a/src/Repl.Core/Output/HumanOutputTransformer.cs b/src/Repl.Core/Output/HumanOutputTransformer.cs index 63340103..54a667d3 100644 --- a/src/Repl.Core/Output/HumanOutputTransformer.cs +++ b/src/Repl.Core/Output/HumanOutputTransformer.cs @@ -3,10 +3,11 @@ using System.ComponentModel.DataAnnotations; using System.Reflection; using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Nodes; namespace Repl; -internal sealed class HumanOutputTransformer : IResultFlowOutputTransformer +internal sealed class HumanOutputTransformer : ILayoutDeclaringOutputTransformer { private readonly Func _resolveRenderSettings; @@ -30,98 +31,126 @@ public HumanOutputTransformer(Func resolveRenderSettings) public ValueTask TransformAsync(object? value, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - var settings = _resolveRenderSettings(); + return ValueTask.FromResult(Render(value, _resolveRenderSettings()).Text); + } - if (value is null) - { - return ValueTask.FromResult(string.Empty); - } + public ValueTask RenderAsync(object? value, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.FromResult(Render(value, _resolveRenderSettings())); + } - if (value is IReplPage page) + public ValueTask RenderPageAsync(IReplPage page, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(page); + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.FromResult(RenderPageBody(page, _resolveRenderSettings())); + } + + private static RenderedPayload Render(object? value, HumanRenderSettings settings) => value switch + { + null => RenderedPayload.Plain(string.Empty), + IReplPage page => RenderPageWithFooter(page, settings), + IReplResult replResult => RenderReplResult(replResult, settings), + string text => RenderedPayload.Plain(text), + // Before the enumerable arm: a JsonObject enumerates as key/value pairs, and reflecting over those would + // show JsonNode's CLR members instead of the data. + _ when JsonHumanShape.TryGetNode(value, out var node) => RenderJson(node, settings), + System.Collections.IEnumerable enumerable => RenderTopLevelEnumerable(enumerable, settings), + _ when TryRenderObject(value, settings, out var objectText) => RenderedPayload.Plain(objectText), + _ => RenderedPayload.Plain(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty), + }; + + private static RenderedPayload RenderPageWithFooter(IReplPage page, HumanRenderSettings settings) => + RenderPageBody(page, settings).WithFooterLine(ResultFlowPageFooterBuilder.RenderHuman(page)); + + private static RenderedPayload RenderPageBody(IReplPage page, HumanRenderSettings settings) + { + if (page.UntypedItems.Count == 0) { - return ValueTask.FromResult(RenderPage(page, settings)); + return RenderedPayload.Plain("No results."); } - if (value is IReplResult replResult) + // By its declared item type: a page of JSON nulls has no item to be recognized by. + if (JsonHumanShape.IsJsonType(page.ItemType)) { - return ValueTask.FromResult(RenderReplResult(replResult, settings)); + return RenderJsonItems(page.UntypedItems, settings); } - if (value is string text) + return RenderItems([.. page.UntypedItems], depth: 0, settings); + } + + private static RenderedPayload RenderTopLevelEnumerable( + System.Collections.IEnumerable enumerable, + HumanRenderSettings settings) + { + var lines = enumerable + .Cast() + .ToArray(); + if (lines.Length == 0) { - return ValueTask.FromResult(text); + return RenderedPayload.Plain("No results."); } - if (value is System.Collections.IEnumerable enumerable) + if (TryRenderTable(lines, settings, out var table)) { - var lines = enumerable - .Cast() - .ToArray(); - if (lines.Length == 0) - { - return ValueTask.FromResult("No results."); - } - - if (TryRenderTable(lines, settings, includeHeader: true, out var tableText)) - { - return ValueTask.FromResult(tableText); - } - - var scalarLines = lines - .Select(item => RenderScalar(item, member: null, depth: 0, compactCollection: false, settings.Width, settings)) - .Where(item => !string.IsNullOrWhiteSpace(item)) - .ToArray(); + return table; + } - if (scalarLines.Length == 0) - { - return ValueTask.FromResult("No results."); - } + var scalarLines = lines + .Select(item => RenderScalar(item, member: null, depth: 0, compactCollection: false, settings.Width, settings)) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .ToArray(); - return ValueTask.FromResult(string.Join(Environment.NewLine, scalarLines)); - } + return RenderedPayload.Plain(scalarLines.Length == 0 ? "No results." : string.Join(Environment.NewLine, scalarLines)); + } - if (TryRenderObject(value, settings, out var objectText)) + private static RenderedPayload RenderJson(JsonNode? node, HumanRenderSettings settings) => node switch + { + JsonObject jsonObject => RenderedPayload.Plain(RenderJsonObject(jsonObject, settings)), + JsonArray { Count: 0 } => RenderedPayload.Plain("No results."), + // Its own path rather than the generic collection one, which recognizes JSON by its first non-null + // item and so has nothing to go on for an array of nulls. + JsonArray jsonArray => RenderJsonItems([.. jsonArray], settings), + _ => RenderedPayload.Plain(JsonHumanShape.Literal(node)), + }; + + // The table's columns come from its own rows' keys rather than from a type, so a later page can name others; + // declaring those keys lets the pager tell a repeated header from one it must keep. + private static RenderedPayload RenderJsonItems(IReadOnlyList items, HumanRenderSettings settings) + { + if (!JsonHumanShape.TryGetObjectRows(items, out var columns, out var rows)) { - return ValueTask.FromResult(objectText); + var literals = string.Join( + Environment.NewLine, + items.Select(item => JsonHumanShape.TryLiteral(item, out var literal) + ? literal + : RenderScalar(item, member: null, depth: 0, compactCollection: true, settings.Width, settings))); + return RenderedPayload.Plain(literals); } - return ValueTask.FromResult( - Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty); + var tableRows = new List(rows.Length + 1) { columns.Select(JsonHumanShape.Label).ToArray() }; + tableRows.AddRange(rows.Select(row => columns.Select(column => JsonHumanShape.Cell(row, column)).ToArray())); + return FormatTable(tableRows, settings, columns); } - public ValueTask TransformPageAsync( - IReplPage page, - ResultFlowPageRenderMode mode, - CancellationToken cancellationToken = default) + private static string RenderJsonObject(JsonObject jsonObject, HumanRenderSettings settings) { - ArgumentNullException.ThrowIfNull(page); - cancellationToken.ThrowIfCancellationRequested(); - return ValueTask.FromResult(RenderPage(page, _resolveRenderSettings(), mode)); - } - - private static string RenderPage(IReplPage page, HumanRenderSettings settings) => - RenderPage(page, settings, ResultFlowPageRenderMode.Initial, includeFooter: true); + if (jsonObject.Count == 0) + { + return "{}"; + } - private static string RenderPage(IReplPage page, HumanRenderSettings settings, ResultFlowPageRenderMode mode) => - RenderPage(page, settings, mode, includeFooter: false); + var entries = new List(jsonObject.Count); + foreach (var property in jsonObject) + { + entries.Add(new RenderedEntry( + JsonHumanShape.Label(property.Key), + JsonHumanShape.Literal(property.Value), + IsMultiline: false)); + } - private static string RenderPage( - IReplPage page, - HumanRenderSettings settings, - ResultFlowPageRenderMode mode, - bool includeFooter) - { - var body = page.UntypedItems.Count == 0 - ? "No results." - : RenderCollection( - page.UntypedItems, - depth: 0, - settings, - includeTableHeader: mode == ResultFlowPageRenderMode.Initial); - var footer = includeFooter ? ResultFlowPageFooterBuilder.RenderHuman(page) : string.Empty; - return string.IsNullOrWhiteSpace(footer) - ? body - : string.Concat(body, Environment.NewLine, footer); + return RenderEntries(entries, settings); } private static bool TryRenderObject(object value, HumanRenderSettings settings, out string text) @@ -137,6 +166,12 @@ private static bool TryRenderObject(object value, HumanRenderSettings settings, foreach (var member in members) { var memberValue = member.Property.GetValue(value); + if (JsonHumanShape.TryGetNode(memberValue, out var jsonValue)) + { + entries.Add(new RenderedEntry(member.Label, JsonHumanShape.Literal(jsonValue), IsMultiline: false)); + continue; + } + if (memberValue is System.Collections.IEnumerable collectionValue && memberValue is not string) { @@ -173,76 +208,89 @@ private static bool TryRenderObject(object value, HumanRenderSettings settings, private static string RenderCollection( System.Collections.IEnumerable collection, int depth, - HumanRenderSettings settings, - bool includeTableHeader = true) + HumanRenderSettings settings) => + RenderItems([.. collection.Cast()], depth, settings).Text; + + private static RenderedPayload RenderItems(object?[] values, int depth, HumanRenderSettings settings) { - var values = collection.Cast().ToArray(); if (values.Length == 0) { - return string.Empty; + return RenderedPayload.Plain(string.Empty); } - if (TryRenderTable(values, settings, includeTableHeader, out var tableText)) + if (TryRenderTable(values, settings, out var table)) { - return tableText; + return table; } - return string.Join( + var list = string.Join( Environment.NewLine, values.Select(value => $"- {RenderScalar(value, member: null, depth, compactCollection: false, settings.Width, settings)}")); + return RenderedPayload.Plain(list); } private static bool TryRenderTable( object?[] values, HumanRenderSettings settings, - bool includeHeader, - out string text) + [NotNullWhen(true)] out RenderedPayload? rendered) { var firstNonNull = values.FirstOrDefault(value => value is not null); if (firstNonNull is null) { - text = string.Empty; + rendered = null; return false; } + // Only once the first item is JSON: ordinary collections must not pay for the JSON row scan. + if (JsonHumanShape.IsJson(firstNonNull)) + { + rendered = RenderJsonItems(values, settings); + return true; + } + if (IsSimpleValue(firstNonNull.GetType())) { - text = string.Join( + var lines = string.Join( Environment.NewLine, values.Select(value => RenderScalar(value, member: null, depth: 0, compactCollection: true, settings.Width, settings))); + rendered = RenderedPayload.Plain(lines); return true; } var members = GetDisplayMembers(firstNonNull.GetType()); if (members.Length == 0) { - text = string.Empty; + rendered = null; return false; } - var rows = BuildTableRows(values, members, settings, includeHeader); - var style = includeHeader && settings.UseAnsi - ? TextTableStyle.ForHeader(settings.Palette.TableHeaderStyle) - : TextTableStyle.None; - text = TextTableFormatter.FormatRows( + var rows = BuildTableRows(values, members, settings); + rendered = FormatTable(rows, settings, columns: rows[0]); + return true; + } + + // The first row is the header. TextTableFormatter writes it on one line (it truncates a cell, never wraps it) + // and, without ANSI styling to set it apart, a separator line under it: the layout declares exactly that. + private static RenderedPayload FormatTable( + List rows, + HumanRenderSettings settings, + IReadOnlyList columns) + { + var separated = !settings.UseAnsi; + var text = TextTableFormatter.FormatRows( rows, settings.Width, - includeHeaderSeparator: includeHeader && !settings.UseAnsi, - style); - return true; + includeHeaderSeparator: separated, + separated ? TextTableStyle.None : TextTableStyle.ForHeader(settings.Palette.TableHeaderStyle)); + return new RenderedPayload(text, new RenderedLayout(separated ? 2 : 1, columns)); } private static List BuildTableRows( object?[] values, DisplayMember[] members, - HumanRenderSettings settings, - bool includeHeader) + HumanRenderSettings settings) { - var rows = new List(values.Length + (includeHeader ? 1 : 0)); - if (includeHeader) - { - rows.Add(members.Select(member => member.Label).ToArray()); - } + var rows = new List(values.Length + 1) { members.Select(member => member.Label).ToArray() }; foreach (var item in values) { @@ -312,6 +360,11 @@ private static string RenderScalar( return text; } + if (JsonHumanShape.TryGetNode(value, out var node)) + { + return JsonHumanShape.Literal(node); + } + var valueType = value.GetType(); if (IsSimpleValue(valueType)) { @@ -383,9 +436,10 @@ private static DisplayMember[] GetDisplayMembers( var displayFormat = property.GetCustomAttribute(); return new DisplayMember( property, - string.IsNullOrWhiteSpace(display?.GetName()) ? property.Name : display!.GetName()!, + TextTableFormatter.ToSingleLine( + string.IsNullOrWhiteSpace(display?.GetName()) ? property.Name : display!.GetName()!), display?.GetOrder(), - displayFormat?.NullDisplayText); + displayFormat?.NullDisplayText ?? JsonHumanShape.NullText(property.PropertyType)); }) .Where(member => member is not null) .Select(member => member!) @@ -393,13 +447,27 @@ private static DisplayMember[] GetDisplayMembers( .ThenBy(member => member.Property.MetadataToken) .ToArray(); - private sealed record DisplayMember( + private sealed record DisplayMember( PropertyInfo Property, string Label, int? Order, string? NullDisplayText); - private static string RenderReplResult(IReplResult result, HumanRenderSettings settings) + private static RenderedPayload RenderReplResult(IReplResult result, HumanRenderSettings settings) + { + // The message comes first, so no header starts the payload; a page's footer still ends it. + if (result.Details is IReplPage page) + { + var rendered = RenderPageWithFooter(page, settings); + return new RenderedPayload( + $"{DescribeResult(result)}{Environment.NewLine}{rendered.Text}", + RenderedLayout.None.WithFooter(rendered.Layout.FooterLineCount)); + } + + return RenderedPayload.Plain(RenderReplResultText(result, settings)); + } + + private static string DescribeResult(IReplResult result) { var prefix = result.Kind.ToLowerInvariant() switch { @@ -412,18 +480,22 @@ private static string RenderReplResult(IReplResult result, HumanRenderSettings s _ => "Result", }; - var message = string.IsNullOrWhiteSpace(prefix) + return string.IsNullOrWhiteSpace(prefix) ? result.Message : $"{prefix}: {result.Message}"; + } + private static string RenderReplResultText(IReplResult result, HumanRenderSettings settings) + { + var message = DescribeResult(result); if (result.Details is null) { return message; } - if (result.Details is IReplPage page) + if (JsonHumanShape.TryGetNode(result.Details, out var jsonDetails)) { - return $"{message}{Environment.NewLine}{RenderPage(page, settings)}"; + return $"{message}{Environment.NewLine}{RenderJson(jsonDetails, settings).Text}"; } if (TryRenderDictionary(result.Details, settings, out var dictionaryText)) diff --git a/src/Repl.Core/Output/JsonHumanShape.cs b/src/Repl.Core/Output/JsonHumanShape.cs new file mode 100644 index 00000000..cedabcae --- /dev/null +++ b/src/Repl.Core/Output/JsonHumanShape.cs @@ -0,0 +1,249 @@ +using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Repl; + +/// +/// Recognizes JSON data — and — for the human renderers, +/// which would otherwise reflect over the CLR members of those types (Options, Parent, +/// Root, Count, ValueKind) instead of showing the data. +/// +/// +/// Values render as compact JSON literals, so a string reads "x", an explicit JSON null reads +/// null, and a nested object or array stays visible as itself. The relaxed encoder keeps non-ASCII +/// text readable and escapes every control character; format characters (bidirectional overrides and +/// isolates, zero-width marks), which it lets through, are escaped here too, so a JSON string can neither +/// drive the terminal it is printed to nor make it display something other than the data. Nothing here +/// mutates the value it reads. +/// +internal static class JsonHumanShape +{ + private static readonly JsonWriterOptions LiteralWriterOptions = new() + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + public static bool IsJson([NotNullWhen(true)] object? value) => value is JsonNode or JsonElement; + + /// + /// What a declared as reads as: a JSON null, as --json + /// writes it, for a JSON type; , the renderer's own default, for any other. + /// + public static string? NullText(Type type) => IsJsonType(type) ? Literal(node: null) : null; + + /// Whether items declared as are JSON data, nullable elements included. + public static bool IsJsonType(Type type) => + typeof(JsonNode).IsAssignableFrom(type) || (Nullable.GetUnderlyingType(type) ?? type) == typeof(JsonElement); + + /// + /// Reads as a JSON node. A is copied into nodes, and the + /// element itself is never modified. A JSON null comes back as a node with + /// . + /// + public static bool TryGetNode(object? value, out JsonNode? node) + { + switch (value) + { + case JsonNode jsonNode: + node = jsonNode; + return true; + case JsonElement element: + node = FromElement(element); + return true; + default: + node = null; + return false; + } + } + + // Built property by property rather than through JsonObject.Create: an element may repeat a property name, + // which a JsonObject cannot hold, and enumerating one created over such an element throws. The last value + // wins, as JavaScript reads it. Scalars keep referring to the element rather than copying its text. + private static JsonNode? FromElement(JsonElement element) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + var jsonObject = new JsonObject(); + foreach (var property in element.EnumerateObject()) + { + jsonObject[property.Name] = FromElement(property.Value); + } + + return jsonObject; + case JsonValueKind.Array: + var jsonArray = new JsonArray(); + foreach (var item in element.EnumerateArray()) + { + jsonArray.Add(FromElement(item)); + } + + return jsonArray; + case JsonValueKind.Null or JsonValueKind.Undefined: + return null; + default: + return JsonValue.Create(element); + } + } + + /// + /// The literal for an item of a JSON collection: — a JSON null — reads + /// null. for a value that is not JSON at all. + /// + public static bool TryLiteral(object? value, [NotNullWhen(true)] out string? literal) + { + if (value is null) + { + literal = Literal(node: null); + return true; + } + + if (TryGetNode(value, out var node)) + { + literal = Literal(node); + return true; + } + + literal = null; + return false; + } + + /// + /// The compact JSON text of . Written with no serializer options, so a + /// wrapping a CLR object keeps the type information it was created with; + /// options without a type resolver make that write throw. + /// + public static string Literal(JsonNode? node) + { + if (node is null) + { + return "null"; + } + + var buffer = new ArrayBufferWriter(); + // Synchronous on purpose: the writer targets an in-memory buffer, so disposing it only flushes there. +#pragma warning disable MA0045 + using (var writer = new Utf8JsonWriter(buffer, LiteralWriterOptions)) +#pragma warning restore MA0045 + { + node.WriteTo(writer, options: null); + } + + return EscapeFormatCharacters(Encoding.UTF8.GetString(buffer.WrittenSpan)); + } + + /// A property name as a label: escaped like a JSON string, without the quotes. + public static string Label(string key) => + EscapeFormatCharacters(JsonEncodedText.Encode(key, JavaScriptEncoder.UnsafeRelaxedJsonEscaping).ToString()); + + /// + /// Reads as rows of JSON objects. The columns are the union of their keys in + /// first-seen order, so a key missing from one row leaves its cell empty rather than dropping the row. + /// Fails unless every value is a JSON object with at least one key. A JSON null or an empty object would + /// render as a blank row, which reads as no row at all and, as the last one, is trimmed away with the payload's + /// trailing whitespace; those rows then read as their literals instead. + /// + public static bool TryGetObjectRows( + IReadOnlyList values, + [NotNullWhen(true)] out string[]? columns, + [NotNullWhen(true)] out JsonObject[]? rows) + { + columns = null; + rows = null; + if (values.Count == 0) + { + return false; + } + + var converted = new JsonObject[values.Count]; + var seen = new HashSet(StringComparer.Ordinal); + var ordered = new List(); + for (var i = 0; i < values.Count; i++) + { + if (!TryGetNode(values[i], out var node) || node is not JsonObject { Count: > 0 } row) + { + return false; + } + + converted[i] = row; + foreach (var property in row) + { + if (seen.Add(property.Key)) + { + ordered.Add(property.Key); + } + } + } + + columns = [.. ordered]; + rows = converted; + return true; + } + + /// + /// The cell for : empty when the row does not have the key. Matched ordinally, the + /// way the columns were collected, even in a row built with case-insensitive property names. + /// + public static string Cell(JsonObject row, string column) + { + if (row.IndexOf(column) is not (>= 0 and var index)) + { + return string.Empty; + } + + var property = row.GetAt(index); + return string.Equals(property.Key, column, StringComparison.Ordinal) ? Literal(property.Value) : string.Empty; + } + + // Format characters can only appear inside a JSON string here, where \uXXXX is the same character + // to a JSON reader. Returns the input unchanged — no allocation — when it has none. + private static string EscapeFormatCharacters(string text) + { + var index = IndexOfFormatCharacter(text); + if (index < 0) + { + return text; + } + + var builder = new StringBuilder(text.Length + 12); + builder.Append(text, 0, index); + Span units = stackalloc char[2]; + foreach (var rune in text.AsSpan(index).EnumerateRunes()) + { + var written = rune.EncodeToUtf16(units); + if (Rune.GetUnicodeCategory(rune) != UnicodeCategory.Format) + { + builder.Append(units[..written]); + continue; + } + + for (var i = 0; i < written; i++) + { + builder.Append(CultureInfo.InvariantCulture, $"\\u{(int)units[i]:X4}"); + } + } + + return builder.ToString(); + } + + private static int IndexOfFormatCharacter(string text) + { + var index = 0; + foreach (var rune in text.EnumerateRunes()) + { + if (Rune.GetUnicodeCategory(rune) == UnicodeCategory.Format) + { + return index; + } + + index += rune.Utf16SequenceLength; + } + + return -1; + } +} diff --git a/src/Repl.Core/Output/RenderedLayout.cs b/src/Repl.Core/Output/RenderedLayout.cs new file mode 100644 index 00000000..b2261e9c --- /dev/null +++ b/src/Repl.Core/Output/RenderedLayout.cs @@ -0,0 +1,50 @@ +namespace Repl; + +/// +/// What a result-flow transformer rendered around a payload's data: the header its first +/// lines hold, the columns that header names, and the footer its last +/// lines hold. The pager takes these lines as declared rather than inferring them +/// from the text's styling. +/// +internal sealed class RenderedLayout +{ + /// How many of the payload's first lines are its header, separator included. + /// The columns the header names, in order, as the transformer keys them. + /// How many of the payload's last lines are its footer. + public RenderedLayout(int headerLineCount, IReadOnlyList columns, int footerLineCount = 0) + { + ArgumentOutOfRangeException.ThrowIfNegative(headerLineCount); + ArgumentNullException.ThrowIfNull(columns); + ArgumentOutOfRangeException.ThrowIfNegative(footerLineCount); + if (headerLineCount == 0 != (columns.Count == 0)) + { + throw new ArgumentException("A header names at least one column, and only a header names columns.", nameof(columns)); + } + + HeaderLineCount = headerLineCount; + Columns = columns; + FooterLineCount = footerLineCount; + } + + /// A payload rendered with neither a header nor a footer, such as a list or an object's fields. + public static RenderedLayout None { get; } = new(headerLineCount: 0, columns: []); + + public int HeaderLineCount { get; } + + public IReadOnlyList Columns { get; } + + public int FooterLineCount { get; } + + /// + /// Whether this layout's header names the same columns as 's, compared ordinally: the + /// pager drops a fetched page's header only when it repeats the columns the previous page showed. + /// + public bool NamesSameColumns(RenderedLayout other) + { + ArgumentNullException.ThrowIfNull(other); + return HeaderLineCount > 0 && Columns.SequenceEqual(other.Columns, StringComparer.Ordinal); + } + + /// This layout with a footer of lines appended below the data. + public RenderedLayout WithFooter(int footerLineCount) => new(HeaderLineCount, Columns, footerLineCount); +} diff --git a/src/Repl.Core/Output/RenderedPayload.cs b/src/Repl.Core/Output/RenderedPayload.cs new file mode 100644 index 00000000..65dad45c --- /dev/null +++ b/src/Repl.Core/Output/RenderedPayload.cs @@ -0,0 +1,16 @@ +namespace Repl; + +/// A result-flow transformer's rendering, with the layout it declares for the pager. +/// The rendered text. +/// The header and footer starts and ends with. +internal sealed record RenderedPayload(string Text, RenderedLayout Layout) +{ + /// Text with neither a header nor a footer. + public static RenderedPayload Plain(string text) => new(text, RenderedLayout.None); + + /// This payload with , one line, declared below it; unchanged when it is blank. + public RenderedPayload WithFooterLine(string footer) => + string.IsNullOrWhiteSpace(footer) + ? this + : new RenderedPayload(string.Concat(Text, Environment.NewLine, footer), Layout.WithFooter(1)); +} diff --git a/src/Repl.Core/Output/TextTableFormatter.cs b/src/Repl.Core/Output/TextTableFormatter.cs index 6832f7e5..9a267102 100644 --- a/src/Repl.Core/Output/TextTableFormatter.cs +++ b/src/Repl.Core/Output/TextTableFormatter.cs @@ -4,6 +4,45 @@ namespace Repl; internal static class TextTableFormatter { + /// + /// with each control character and line or paragraph separator replaced by a space: a + /// table header is declared one line tall, and a line break in a label would push part of it onto a second + /// line for the pager, which splits at every one of them. Returns the input itself, allocating nothing, when + /// it has none. + /// + public static string ToSingleLine(string text) + { + ArgumentNullException.ThrowIfNull(text); + if (!ContainsLineBreaking(text)) + { + return text; + } + + return string.Create(text.Length, text, static (buffer, source) => + { + for (var i = 0; i < source.Length; i++) + { + buffer[i] = BreaksLine(source[i]) ? ' ' : source[i]; + } + }); + } + + private static bool ContainsLineBreaking(string text) + { + foreach (var character in text) + { + if (BreaksLine(character)) + { + return true; + } + } + + return false; + } + + private static bool BreaksLine(char character) => + char.IsControl(character) || character is '\u2028' or '\u2029'; + public static string FormatRows( IReadOnlyList rows, int renderWidth, diff --git a/src/Repl.Core/ResultFlow/PagerPayloadParser.cs b/src/Repl.Core/ResultFlow/PagerPayloadParser.cs index 81ffc2b4..4f325c19 100644 --- a/src/Repl.Core/ResultFlow/PagerPayloadParser.cs +++ b/src/Repl.Core/ResultFlow/PagerPayloadParser.cs @@ -29,6 +29,44 @@ public static ParsedPagerPayload Parse(string payload, PagerHeader? header, bool return new ParsedPagerPayload(resolvedHeader, content); } + /// + /// Parses a payload whose transformer declared its layout: its header and footer are exactly the lines the + /// layout names, so nothing is inferred from styling and no line is dropped for reading like a header or a + /// footer. The first payload's header is pinned. A later payload's header is dropped when it names the columns + /// the previous payload showed, and kept when it names others, or its rows would sit under headings that are + /// not theirs. + /// + /// The rendered payload. + /// The pinned header, or for the first payload. + /// The previous payload's layout, or for the first payload. + /// The layout the payload's transformer declared. + public static ParsedPagerPayload ParseDeclared( + string payload, + PagerHeader? header, + RenderedLayout? previousLayout, + RenderedLayout layout) + { + var lines = SplitLines(payload); + var headerLineCount = Math.Min(layout.HeaderLineCount, lines.Count); + var contentEnd = Math.Max(headerLineCount, lines.Count - layout.FooterLineCount); + var content = new List(contentEnd); + if (header is null) + { + content.AddRange(lines.Take(headerLineCount..contentEnd)); + return new ParsedPagerPayload(DeclaredHeader(lines, headerLineCount), content); + } + + var repeatsPreviousColumns = previousLayout is not null && layout.NamesSameColumns(previousLayout); + content.AddRange(lines.Take((repeatsPreviousColumns ? headerLineCount : 0)..contentEnd)); + return new ParsedPagerPayload(header, content); + } + + // Only the detected path compares lines with the header, so a declared one needs no normalized forms. + private static PagerHeader DeclaredHeader(List lines, int headerLineCount) => + headerLineCount == 0 + ? PagerHeader.Empty + : new PagerHeader([.. lines.Take(headerLineCount)], PagerHeader.Empty.NormalizedLines); + private static PagerHeader DetectHeader(List lines) { if (lines.Count == 0) diff --git a/src/Repl.Core/ResultFlow/PagerSession.cs b/src/Repl.Core/ResultFlow/PagerSession.cs index 22d4a7dc..ea78e041 100644 --- a/src/Repl.Core/ResultFlow/PagerSession.cs +++ b/src/Repl.Core/ResultFlow/PagerSession.cs @@ -3,19 +3,37 @@ namespace Repl; internal sealed class PagerSession { private readonly PagerHeader _header; + private readonly bool _declaresLayout; + private RenderedLayout? _previousLayout; private readonly int _maxBufferedLines; private readonly List _lines = []; private readonly IReadOnlyList _readOnlyLines; - public PagerSession(string initialPayload, bool hasMorePayload, int maxBufferedLines) + /// The first payload. + /// Whether another payload can be fetched. + /// How many content lines the session buffers at most. + /// + /// The layout the payload's transformer declared, or for a transformer that declares + /// none, whose headers and footers are then detected from the text. Decided once, for every payload of the + /// session: one transformer renders them all. + /// + public PagerSession( + string initialPayload, + bool hasMorePayload, + int maxBufferedLines, + RenderedLayout? layout = null) { _maxBufferedLines = Math.Max(1, maxBufferedLines); _readOnlyLines = _lines.AsReadOnly(); - var parsed = PagerPayloadParser.Parse(initialPayload, header: null); - _header = parsed.Header; - AppendContent(parsed.ContentLines, hasMorePayload); - PageSize = 1; - NextWindow = 1; + _declaresLayout = layout is not null; + _previousLayout = layout; + var parsed = layout is null + ? PagerPayloadParser.Parse(initialPayload, header: null) + : PagerPayloadParser.ParseDeclared(initialPayload, header: null, previousLayout: null, layout); + _header = parsed.Header; + AppendContent(parsed.ContentLines, hasMorePayload); + PageSize = 1; + NextWindow = 1; } public IReadOnlyList HeaderLines => _header.Lines; @@ -34,9 +52,32 @@ public PagerSession(string initialPayload, bool hasMorePayload, int maxBufferedL public bool SourceReturnedNoData { get; set; } - public void Append(string payload, bool hasMorePayload, bool containsPresentationChrome = true) + /// The fetched payload. + /// Whether another payload can be fetched. + /// + /// Whether an undeclared payload can carry footer lines to strip; a declared layout names its footer itself. + /// + /// + /// The layout the payload's transformer declared; ignored, like a missing one, when the session detects. + /// + public void Append( + string payload, + bool hasMorePayload, + bool containsPresentationChrome = true, + RenderedLayout? layout = null) { - var parsed = PagerPayloadParser.Parse(payload, _header, containsPresentationChrome); + ParsedPagerPayload parsed; + if (_declaresLayout) + { + var declared = layout ?? RenderedLayout.None; + parsed = PagerPayloadParser.ParseDeclared(payload, _header, _previousLayout, declared); + _previousLayout = declared; + } + else + { + parsed = PagerPayloadParser.Parse(payload, _header, containsPresentationChrome); + } + AppendContent(parsed.ContentLines, hasMorePayload); } diff --git a/src/Repl.Core/ResultFlow/ResultFlowPager.cs b/src/Repl.Core/ResultFlow/ResultFlowPager.cs index 4ca68dc6..05837855 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowPager.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowPager.cs @@ -14,7 +14,11 @@ internal static class ResultFlowPager private static readonly System.Text.CompositeFormat FullStatusBufferLimitFormat = System.Text.CompositeFormat.Parse(FullStatusBufferLimit); - internal static int CountLines(string payload) => PagerPayloadParser.Parse(payload, header: null).TotalLineCount; + internal static int CountLines(string payload, RenderedLayout? layout = null) => + (layout is null + ? PagerPayloadParser.Parse(payload, header: null) + : PagerPayloadParser.ParseDeclared(payload, header: null, previousLayout: null, layout)) + .TotalLineCount; internal static ValueTask WriteAsync( string payload, @@ -53,6 +57,7 @@ internal static async ValueTask WriteAsync( options.VisibleRowsProvider, options.AnsiEnabled, options.HasMorePayload, + options.PayloadLayout, options.FetchNextPayload, cancellationToken) .ConfigureAwait(false)) @@ -60,7 +65,7 @@ internal static async ValueTask WriteAsync( return; } - var session = new PagerSession(payload, options.HasMorePayload, maxBufferedLines); + var session = new PagerSession(payload, options.HasMorePayload, maxBufferedLines, options.PayloadLayout); await RenderBuiltInAsync( mode, session, @@ -140,6 +145,7 @@ private static async ValueTask TryRenderCustomAsync( Func? visibleRowsProvider, bool ansiEnabled, bool hasMorePayload, + RenderedLayout? payloadLayout, Func>? fetchNextPayload, CancellationToken cancellationToken) { @@ -148,6 +154,8 @@ private static async ValueTask TryRenderCustomAsync( return false; } + var previousLayout = payloadLayout; + foreach (var renderer in pagerRenderers) { if (renderer.Mode != mode) @@ -175,7 +183,26 @@ await renderer.RenderAsync( async ValueTask FetchPublicPayloadAsync(CancellationToken token) { var next = await fetchNextPayload!(token).ConfigureAwait(false); - return next is null ? null : new ReplPagerPayload(next.Payload, next.HasMore); + return next is null ? null : new ReplPagerPayload(WithoutRepeatedHeader(next), next.HasMore); + } + + // A custom renderer gets text only, so a declared header naming the previous page's columns is stripped + // for it, as the built-in pager drops it; a header naming other columns stays with its rows. + string WithoutRepeatedHeader(ResultFlowPagerPage page) + { + if (previousLayout is not { } previous || page.Layout is not { } layout) + { + return page.Payload; + } + + previousLayout = layout; + if (!layout.NamesSameColumns(previous)) + { + return page.Payload; + } + + var parsed = PagerPayloadParser.ParseDeclared(page.Payload, PagerHeader.Empty, previous, layout); + return string.Join(Environment.NewLine, parsed.ContentLines); } } @@ -282,7 +309,7 @@ private static async ValueTask TryFetchIntoSessionAsync( return false; } - session.Append(nextPayload.Payload, nextPayload.HasMore, nextPayload.ContainsPresentationChrome); + session.Append(nextPayload.Payload, nextPayload.HasMore, nextPayload.ContainsPresentationChrome, nextPayload.Layout); return true; } diff --git a/src/Repl.Core/ResultFlow/ResultFlowPagerOptions.cs b/src/Repl.Core/ResultFlow/ResultFlowPagerOptions.cs index 76c8758a..dc3af34c 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowPagerOptions.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowPagerOptions.cs @@ -12,6 +12,12 @@ internal sealed record ResultFlowPagerOptions public bool HasMorePayload { get; init; } + /// + /// The layout the first payload's transformer declared, or to detect its header and + /// footer from the text. It decides for the whole session: fetched payloads come from the same transformer. + /// + public RenderedLayout? PayloadLayout { get; init; } + public Func>? FetchNextPayload { get; init; } public IReadOnlyList? PagerRenderers { get; init; } diff --git a/src/Repl.Core/ResultFlow/ResultFlowPagerPage.cs b/src/Repl.Core/ResultFlow/ResultFlowPagerPage.cs index 4b95bd55..abf388e8 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowPagerPage.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowPagerPage.cs @@ -1,6 +1,14 @@ namespace Repl; +/// The rendered page. +/// Whether another page can be fetched. +/// Whether the payload can carry footer lines for the pager to strip. +/// +/// The layout the page's transformer declared, or when it declares none and the pager +/// detects its header and footer from the text. +/// internal sealed record ResultFlowPagerPage( string Payload, bool HasMore, - bool ContainsPresentationChrome = true); + bool ContainsPresentationChrome = true, + RenderedLayout? Layout = null); diff --git a/src/Repl.IntegrationTests/Given_OutputFormatting.cs b/src/Repl.IntegrationTests/Given_OutputFormatting.cs index 68dc3a0f..77ddc710 100644 --- a/src/Repl.IntegrationTests/Given_OutputFormatting.cs +++ b/src/Repl.IntegrationTests/Given_OutputFormatting.cs @@ -1,4 +1,6 @@ using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.Text.Json.Nodes; using System.Text.RegularExpressions; using Repl.Spectre; @@ -269,6 +271,145 @@ public void When_RenderingPageSourceInHumanPager_Then_SpaceFetchesNextPageWithou text.Should().NotContain("Showing "); } + [TestMethod] + [Description("A JSON page source in the human pager: a page with the same keys at other widths adds its row only, and a page with other keys shows its own header, so no row sits under headings that are not its own.")] + public void When_PagingJsonRowsWhoseKeysChange_Then_EachHeaderShowsOnceForItsRows() + { + var sut = ReplApp.Create(); + JsonObject[] rows = + [ + new() { ["id"] = 1, ["name"] = "a" }, + new() { ["id"] = 22, ["name"] = "bbbb" }, + new() { ["name"] = "c", ["id"] = 3 }, + ]; + sut.Map("rows", (IReplPagingContext paging) => + paging.CreateSource((request, _) => + { + var index = request.Cursor is null ? 0 : int.Parse(request.Cursor, CultureInfo.InvariantCulture); + var next = index + 1 < rows.Length ? (index + 1).ToString(CultureInfo.InvariantCulture) : null; + return ValueTask.FromResult(new ReplPage( + [rows[index]], + new ReplPageInfo(request.Cursor, next, rows.Length, request.PageSize))); + })); + + using var output = new StringWriter(); + using var session = ReplSessionIO.SetSession(output, TextReader.Null); + ReplSessionIO.KeyReader = new QueueKeyReader([.. Enumerable.Repeat(Key(ConsoleKey.Spacebar, ' '), 4)]); + ReplSessionIO.WindowSize = (100, 20); + + var exitCode = sut.Run(["rows", "--result:page-size=1", "--no-logo"]); + + exitCode.Should().Be(0); + var lines = StripAnsi(output.ToString()).Split(Environment.NewLine); + lines.Count(line => IsHeader(line, "id", "name")).Should().Be(1, "the second page repeats the first one's columns"); + lines.Count(line => IsHeader(line, "name", "id")).Should().Be(1, "the third page names other columns"); + var third = Array.FindIndex(lines, line => line.Contains("\"c\"", StringComparison.Ordinal)); + third.Should().BePositive(); + lines.Take(third).Last(line => IsHeader(line, "id", "name") || IsHeader(line, "name", "id")) + .Should().Match(line => IsHeader(line, "name", "id"), "the third page's rows sit under its own header"); + } + + [TestMethod] + [Description("A JSON array returned whole and paged: a row that reads like the header is data, so every row reaches the screen rather than being dropped as a repeated header.")] + public void When_PagingAJsonArrayWhoseRowsReadLikeItsHeader_Then_EveryRowIsShown() + { + var sut = ReplApp.Create(); + sut.Map("ones", () => new JsonArray([.. Enumerable.Range(0, 10).Select(_ => (JsonNode)new JsonObject { ["1"] = 1 })])); + + using var output = new StringWriter(); + using var session = ReplSessionIO.SetSession(output, TextReader.Null); + ReplSessionIO.KeyReader = new QueueKeyReader([.. Enumerable.Repeat(Key(ConsoleKey.Spacebar, ' '), 10)]); + ReplSessionIO.WindowSize = (100, 8); + + var exitCode = sut.Run(["ones", "--no-logo"]); + + exitCode.Should().Be(0); + StripAnsi(output.ToString()).Split(Environment.NewLine).Count(line => string.Equals(line.Trim(), "1", StringComparison.Ordinal)) + .Should().Be(11, "the header, then each of the ten rows"); + } + + [TestMethod] + [Description("A long page returned whole is paged without a way to fetch the next one, so its footer, which says how to rerun for it, stays in view.")] + public void When_PagingALongPageReturnedWhole_Then_ItsRerunFooterIsShown() + { + var sut = ReplApp.Create(); + sut.Map("ones", () => new ReplPage( + [.. Enumerable.Range(1, 10).Select(i => new JsonObject { ["id"] = i })], + new ReplPageInfo(Cursor: null, NextCursor: "next", TotalCount: 20, PageSize: 10))); + + using var output = new StringWriter(); + using var session = ReplSessionIO.SetSession(output, TextReader.Null); + ReplSessionIO.KeyReader = new QueueKeyReader([.. Enumerable.Repeat(Key(ConsoleKey.Spacebar, ' '), 10)]); + ReplSessionIO.WindowSize = (100, 8); + + var exitCode = sut.Run(["ones", "--no-logo"]); + + exitCode.Should().Be(0); + StripAnsi(output.ToString()).Should().Contain("Showing 10 of 20. Next data page: rerun with"); + } + + [TestMethod] + [Description("A transformer built against 0.11 implements the page-rendering interface as it shipped: it still pages, asked for the first page and then for continuations, whose header it leaves out.")] + public void When_PagingThroughATransformerBuiltAgainstTheEarlierInterface_Then_ItGetsEachRenderMode() + { + var legacy = new LegacyPageTransformer(); + var sut = ReplApp.Create(); + sut.Options(options => + { + options.Output.AddTransformer("legacy", legacy); + options.Output.AddAlias("legacy", "legacy"); + }); + sut.Map("rows", (IReplPagingContext paging) => + paging.CreateSource((request, _) => + { + var index = request.Cursor is null ? 0 : 1; + return ValueTask.FromResult(new ReplPage( + [new ContactRow($"row {index}", "x@example.com")], + new ReplPageInfo(request.Cursor, index == 0 ? "1" : null, 2, request.PageSize))); + })); + + using var output = new StringWriter(); + using var session = ReplSessionIO.SetSession(output, TextReader.Null); + ReplSessionIO.KeyReader = new QueueKeyReader([.. Enumerable.Repeat(Key(ConsoleKey.Spacebar, ' '), 3)]); + ReplSessionIO.WindowSize = (100, 20); + + var exitCode = sut.Run(["rows", "--legacy", "--result:page-size=1", "--no-logo"]); + + exitCode.Should().Be(0); + legacy.Modes.Should().Equal(ResultFlowPageRenderMode.Initial, ResultFlowPageRenderMode.Continuation); + output.ToString().Should().Contain("row 0").And.Contain("row 1"); + } + + private sealed class LegacyPageTransformer : IResultFlowOutputTransformer + { + public List Modes { get; } = []; + + public string Name => "legacy"; + + public bool SupportsInteractivePaging => true; + + public ValueTask TransformAsync(object? value, CancellationToken cancellationToken = default) => + ValueTask.FromResult(value is IReplPage page ? Render(page, withHeader: true) : string.Empty); + + public ValueTask TransformPageAsync( + IReplPage page, + ResultFlowPageRenderMode mode, + CancellationToken cancellationToken = default) + { + Modes.Add(mode); + return ValueTask.FromResult(Render(page, withHeader: mode == ResultFlowPageRenderMode.Initial)); + } + + private static string Render(IReplPage page, bool withHeader) => + string.Join( + Environment.NewLine, + (withHeader ? ["Name", "----"] : Array.Empty()) + .Concat(page.UntypedItems.OfType().Select(row => row.Name))); + } + + private static bool IsHeader(string line, params string[] columns) => + line.Split(' ', StringSplitOptions.RemoveEmptyEntries).SequenceEqual(columns, StringComparer.Ordinal); + [TestMethod] [Description("Regression guard: verifies paging.CreateSource receives the caller cursor and suggested page size through the first source request.")] public void When_PageSourceIsCreatedFromPagingContext_Then_FirstRequestUsesCurrentPagingIntent() diff --git a/src/Repl.Spectre/SingleLineLabel.cs b/src/Repl.Spectre/SingleLineLabel.cs new file mode 100644 index 00000000..9a46a41b --- /dev/null +++ b/src/Repl.Spectre/SingleLineLabel.cs @@ -0,0 +1,51 @@ +using Spectre.Console.Rendering; + +namespace Repl.Spectre; + +/// +/// A bold table header label that always renders on one line, truncated when its column cannot fit it, the way the +/// default human transformer truncates. The pager is told a table's header is one line tall, so a header cell must +/// never wrap onto a second one. +/// +internal sealed class SingleLineLabel : Renderable +{ + private const string Ellipsis = "..."; + private static readonly Style Bold = new(decoration: Decoration.Bold); + + private readonly Segment _label; + private readonly int _width; + + public SingleLineLabel(string text) + { + ArgumentNullException.ThrowIfNull(text); + _label = new Segment(TextTableFormatter.ToSingleLine(text), Bold); + _width = _label.CellCount(); + } + + // Unable to wrap, it has no narrower form than its whole width. + protected override Measurement Measure(RenderOptions options, int maxWidth) + { + var width = Math.Min(_width, maxWidth); + return new Measurement(width, width); + } + + // Always one segment, even for a column with no room at all, so the header row keeps its one line. + protected override IEnumerable Render(RenderOptions options, int maxWidth) + { + if (_width <= maxWidth) + { + return [_label]; + } + + if (maxWidth <= Ellipsis.Length) + { + return [Segment.Truncate(_label, Math.Max(0, maxWidth)) ?? new Segment(string.Empty)]; + } + + return + [ + Segment.Truncate(_label, maxWidth - Ellipsis.Length) ?? new Segment(string.Empty), + new Segment(Ellipsis, Bold), + ]; + } +} diff --git a/src/Repl.Spectre/SpectreHumanOutputTransformer.cs b/src/Repl.Spectre/SpectreHumanOutputTransformer.cs index 81e73e8f..3bc99522 100644 --- a/src/Repl.Spectre/SpectreHumanOutputTransformer.cs +++ b/src/Repl.Spectre/SpectreHumanOutputTransformer.cs @@ -5,13 +5,14 @@ using System.Globalization; using System.IO; using System.Reflection; +using System.Text.Json.Nodes; namespace Repl.Spectre; /// /// Output transformer that renders values using light Spectre.Console layouts. /// -internal sealed class SpectreHumanOutputTransformer : IResultFlowOutputTransformer +internal sealed class SpectreHumanOutputTransformer : ILayoutDeclaringOutputTransformer { private readonly Func _resolveRenderSettings; private readonly OutputOptions? _outputOptions; @@ -46,34 +47,37 @@ public SpectreHumanOutputTransformer( public ValueTask TransformAsync(object? value, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.FromResult(Render(value).Text); + } - if (value is null) - { - return ValueTask.FromResult(string.Empty); - } - - return ValueTask.FromResult(value switch - { - HelpRenderDocument help => RenderHelp(help), - IReplPage page => RenderPage(page), - IReplResult replResult => RenderReplResult(replResult), - string text => text, - System.Collections.IEnumerable enumerable => RenderEnumerable(enumerable), - _ when TryRenderObject(value, out var objectText) => objectText, - _ => Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty, - }); + public ValueTask RenderAsync(object? value, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.FromResult(Render(value)); } - public ValueTask TransformPageAsync( - IReplPage page, - ResultFlowPageRenderMode mode, - CancellationToken cancellationToken = default) + public ValueTask RenderPageAsync(IReplPage page, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(page); cancellationToken.ThrowIfCancellationRequested(); - return ValueTask.FromResult(RenderPage(page, mode, includeFooter: false)); + return ValueTask.FromResult(RenderPageBody(page)); } + private RenderedPayload Render(object? value) => value switch + { + null => RenderedPayload.Plain(string.Empty), + HelpRenderDocument help => RenderedPayload.Plain(RenderHelp(help)), + IReplPage page => RenderPageWithFooter(page), + IReplResult replResult => RenderReplResult(replResult), + string text => RenderedPayload.Plain(text), + // Before the enumerable arm: a JsonObject enumerates as key/value pairs, and reflecting over + // those walks JsonNode's Root and Parent, which point back at each other. + _ when JsonHumanShape.TryGetNode(value, out var node) => RenderJson(node), + System.Collections.IEnumerable enumerable => RenderItems([.. enumerable.Cast()]), + _ when TryRenderObject(value, out var objectText) => RenderedPayload.Plain(objectText), + _ => RenderedPayload.Plain(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty), + }; + private string RenderHelp(HelpRenderDocument help) { if (help.IsCommandHelp) @@ -170,7 +174,7 @@ private string RenderCommandList(IReadOnlyList commands, stri return RenderToString(new Rows(sections)); } - private string RenderReplResult(IReplResult result) + private RenderedPayload RenderReplResult(IReplResult result) { var statusMarkup = result.Kind.ToLowerInvariant() switch { @@ -185,91 +189,150 @@ private string RenderReplResult(IReplResult result) if (result.Details is null) { - return RenderToString(new Markup(statusMarkup)); + return RenderedPayload.Plain(RenderToString(new Markup(statusMarkup))); + } + + if (result.Details is IReplPage page) + { + // The page is already rendered: appended as text rather than laid out again, which would count its + // escape sequences as columns and could wrap its lines, the footer among them. The status comes + // first, so no header starts the payload; the page's footer still ends it. + var renderedPage = RenderPageWithFooter(page); + return new RenderedPayload( + string.Concat(RenderToString(new Markup(statusMarkup)), Environment.NewLine, Environment.NewLine, renderedPage.Text), + RenderedLayout.None.WithFooter(renderedPage.Layout.FooterLineCount)); } - var details = result.Details is IReplPage page - ? new Text(RenderPage(page)) + var details = JsonHumanShape.TryGetNode(result.Details, out var node) + // Like a JSON result, as the human transformer renders it, rather than as the compact literal a + // JSON value nested in a result object gets. + ? BuildJson(node) : RenderValueRenderable(result.Details, nested: false); - return RenderToString(new Rows(new IRenderable[] + return RenderedPayload.Plain(RenderToString(new Rows(new IRenderable[] { new Markup(statusMarkup), new Text(string.Empty), details, - })); + }))); } - private string RenderEnumerable( - System.Collections.IEnumerable enumerable, - bool includeTableHeader = true) + private RenderedPayload RenderItems(object?[] items) { - var items = enumerable.Cast().ToArray(); - if (items.Length == 0) - { - return "No results."; - } - var firstNonNull = items.FirstOrDefault(item => item is not null); if (firstNonNull is null) { - return "No results."; + return RenderedPayload.Plain("No results."); } - if (IsSimpleValue(firstNonNull.GetType())) + // Only once the first item is JSON: ordinary collections must not pay for the JSON row scan. + if (JsonHumanShape.IsJson(firstNonNull)) { - return string.Join( - Environment.NewLine, - items.Select(item => Convert.ToString(item, CultureInfo.InvariantCulture) ?? string.Empty)); + return RenderJsonItems(items); } - var members = GetDisplayMembers(firstNonNull.GetType()); + var members = IsSimpleValue(firstNonNull.GetType()) ? [] : GetDisplayMembers(firstNonNull.GetType()); if (members.Length == 0) { - return string.Join( + var lines = string.Join( Environment.NewLine, items.Select(item => Convert.ToString(item, CultureInfo.InvariantCulture) ?? string.Empty)); + return RenderedPayload.Plain(lines); } - return RenderToString(BuildObjectTable(items, members, includeTableHeader)); + return new RenderedPayload( + RenderToString(BuildObjectTable(items, members)), + new RenderedLayout(TableHeaderLineCount, [.. members.Select(member => member.Label)])); } - private string RenderPage(IReplPage page) => - RenderPage(page, ResultFlowPageRenderMode.Initial, includeFooter: true); + private RenderedPayload RenderPageWithFooter(IReplPage page) => + RenderPageBody(page).WithFooterLine(ResultFlowPageFooterBuilder.RenderHuman(page)); + + private RenderedPayload RenderPageBody(IReplPage page) + { + if (page.UntypedItems.Count == 0) + { + return RenderedPayload.Plain("No results."); + } + + // By its declared item type: a page of JSON nulls has no item to be recognized by. + if (JsonHumanShape.IsJsonType(page.ItemType)) + { + return RenderJsonItems(page.UntypedItems); + } + + return RenderItems([.. page.UntypedItems]); + } - private string RenderPage( - IReplPage page, - ResultFlowPageRenderMode mode, - bool includeFooter) + private RenderedPayload RenderJson(JsonNode? node) => node switch + { + JsonObject { Count: 0 } => RenderedPayload.Plain("{}"), + JsonObject jsonObject => RenderedPayload.Plain(RenderToString(BuildJsonObjectGrid(jsonObject))), + JsonArray { Count: 0 } => RenderedPayload.Plain("No results."), + // Its own path rather than RenderItems, which recognizes JSON by its first non-null item and so has + // nothing to go on for an array of nulls. + JsonArray jsonArray => RenderJsonItems([.. jsonArray]), + _ => RenderedPayload.Plain(JsonHumanShape.Literal(node)), + }; + + // The renderable for JSON composed into a larger layout, such as a result's details. Text wrapping an + // already-rendered string would count its escape sequences as columns and wrap its lines again. + private IRenderable BuildJson(JsonNode? node) => node switch { - var body = page.UntypedItems.Count == 0 - ? "No results." - : RenderEnumerable( - page.UntypedItems, - includeTableHeader: mode == ResultFlowPageRenderMode.Initial); - var footer = includeFooter ? RenderPageFooter(page) : string.Empty; - return string.IsNullOrWhiteSpace(footer) - ? body - : string.Concat(body, Environment.NewLine, footer); + JsonObject { Count: > 0 } jsonObject => BuildJsonObjectGrid(jsonObject), + JsonArray jsonArray when JsonHumanShape.TryGetObjectRows([.. jsonArray], out var columns, out var rows) + => BuildJsonTable(columns, rows), + // Literal lines only from here on: plain text, nothing styled. + _ => new Text(RenderJson(node).Text), + }; + + private static Grid BuildJsonObjectGrid(JsonObject jsonObject) => + BuildLabelValueGrid( + [.. jsonObject.Select(static property => + (JsonHumanShape.Label(property.Key), JsonHumanShape.Literal(property.Value))),]); + + // The table's columns come from its own rows' keys rather than from a type, so a later page can name others; + // declaring those keys lets the pager tell a repeated header from one it must keep. + private RenderedPayload RenderJsonItems(IReadOnlyList items) + { + if (JsonHumanShape.TryGetObjectRows(items, out var columns, out var rows)) + { + return new RenderedPayload( + RenderToString(BuildJsonTable(columns, rows)), + new RenderedLayout(TableHeaderLineCount, columns)); + } + + var literals = string.Join( + Environment.NewLine, + items.Select(item => JsonHumanShape.TryLiteral(item, out var literal) ? literal : RenderInlineValue(item))); + return RenderedPayload.Plain(literals); } - private static string RenderPageFooter(IReplPage page) + // Both table builders label their columns with SingleLineLabel, which never wraps, and a borderless table puts + // no rule under its header: the header is exactly one line. + private const int TableHeaderLineCount = 1; + + private static Table BuildJsonTable(string[] columns, JsonObject[] rows) { - var info = page.PageInfo; - var count = page.UntypedItems.Count; - if (info.TotalCount is { } total) + var table = new Table() + .Border(TableBorder.None) + .Collapse(); + foreach (var column in columns) { - var prefix = $"Showing {count.ToString(CultureInfo.InvariantCulture)} of {total.ToString(CultureInfo.InvariantCulture)}."; - return info.HasMore - ? $"{prefix} Next data page: rerun with {ResultFlowCursorPolicy.FormatCliContinuation(info.NextCursor)}." - : prefix; + table.AddColumn(new TableColumn(new SingleLineLabel(JsonHumanShape.Label(column)))); } - if (!info.HasMore) + foreach (var row in rows) { - return string.Empty; + var cells = new IRenderable[columns.Length]; + for (var i = 0; i < columns.Length; i++) + { + cells[i] = CreateTableCell(JsonHumanShape.Cell(row, columns[i]), table.Rows.Count, i); + } + + table.AddRow(cells); } - return $"Showing {count.ToString(CultureInfo.InvariantCulture)} result(s). Next data page: rerun with {ResultFlowCursorPolicy.FormatCliContinuation(info.NextCursor)}."; + return table; } private bool TryRenderObject(object value, out string text) @@ -302,22 +365,15 @@ private Grid BuildObjectGrid(object value, IReadOnlyList members) return grid; } - private static Table BuildObjectTable( - object?[] items, - IReadOnlyList members, - bool includeHeaders = true) + private static Table BuildObjectTable(object?[] items, IReadOnlyList members) { var table = new Table() .Border(TableBorder.None) .Collapse(); - if (!includeHeaders) - { - table.HideHeaders(); - } foreach (var member in members) { - table.AddColumn(new TableColumn($"[bold]{Markup.Escape(member.Label)}[/]")); + table.AddColumn(new TableColumn(new SingleLineLabel(member.Label))); } foreach (var item in items) @@ -425,6 +481,11 @@ private IRenderable RenderValueRenderable( return new Text(text); } + if (JsonHumanShape.TryGetNode(value, out var node)) + { + return new Text(JsonHumanShape.Literal(node)); + } + if (value is System.Collections.IEnumerable enumerable) { var lines = RenderNestedEnumerableLines(enumerable); @@ -461,6 +522,11 @@ private static string RenderInlineValue(object? value, DisplayMember? member = n return text; } + if (JsonHumanShape.TryGetNode(value, out var node)) + { + return JsonHumanShape.Literal(node); + } + if (value is System.Collections.IEnumerable enumerable) { var lines = RenderNestedEnumerableLines(enumerable); @@ -600,7 +666,7 @@ private static DisplayMember[] GetDisplayMembers(Type type) => property, string.IsNullOrWhiteSpace(display?.GetName()) ? property.Name : display!.GetName()!, display?.GetOrder(), - displayFormat?.NullDisplayText); + displayFormat?.NullDisplayText ?? JsonHumanShape.NullText(property.PropertyType)); }) .Where(member => member is not null) .Select(member => member!) diff --git a/src/Repl.SpectreTests/Given_SpectreHumanOutputJson.cs b/src/Repl.SpectreTests/Given_SpectreHumanOutputJson.cs new file mode 100644 index 00000000..f8761968 --- /dev/null +++ b/src/Repl.SpectreTests/Given_SpectreHumanOutputJson.cs @@ -0,0 +1,369 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using Repl.Rendering; + +namespace Repl.SpectreTests; + +/// +/// The Spectre human transformer renders and results as +/// JSON data, the same way the default human transformer does, rather than reflecting over their CLR +/// members. +/// +[TestClass] +public sealed partial class Given_SpectreHumanOutputJson +{ + private static readonly string[] ClrMembers = ["Options", "Parent", "Root", "Count", "ValueKind"]; + + [TestMethod] + [Description("A JsonObject result renders one row per JSON field, values as JSON literals, and none of JsonObject's CLR members.")] + public async Task When_AJsonObjectIsRendered_Then_ItsFieldsAreShown() + { + var output = await RenderAsync(new JsonObject { ["id"] = 1, ["name"] = "Example", ["active"] = true }).ConfigureAwait(false); + + output.Should().MatchRegex(@"id:\s+1"); + output.Should().MatchRegex(@"name:\s+""Example"""); + output.Should().MatchRegex(@"active:\s+true"); + AssertNoClrMembers(output); + } + + [TestMethod] + [Description("Paged JsonObject rows render as a table whose columns are the rows' JSON keys, with a key missing from one row left empty.")] + public async Task When_APageOfJsonObjectsIsRendered_Then_TheKeysBecomeColumns() + { + var page = new ReplPage( + [ + new JsonObject { ["id"] = 1, ["name"] = "first" }, + new JsonObject { ["id"] = 2, ["name"] = "second", ["extra"] = "only-here" }, + ], + new ReplPageInfo(Cursor: null, NextCursor: null, TotalCount: 2, PageSize: 2)); + + var output = await RenderAsync(page).ConfigureAwait(false); + + output.Should().MatchRegex(@"id\s+name\s+extra"); + output.Should().Contain("\"first\"").And.Contain("\"second\"").And.Contain("\"only-here\""); + AssertNoClrMembers(output); + } + + [TestMethod] + [Description("Nested objects and arrays stay visible as compact JSON, and an explicit JSON null reads as null.")] + public async Task When_AJsonObjectHasNestedValuesAndNulls_Then_TheyArePreserved() + { + var output = await RenderAsync(new JsonObject + { + ["owner"] = new JsonObject { ["login"] = "octo" }, + ["tags"] = new JsonArray("a", "b"), + ["deleted"] = null, + }).ConfigureAwait(false); + + output.Should().Contain("""{"login":"octo"}"""); + output.Should().Contain("""["a","b"]"""); + output.Should().MatchRegex(@"deleted:\s+null"); + AssertNoClrMembers(output); + } + + [TestMethod] + [Description("A JsonElement is JSON data too, and renders the same way as the equivalent JsonNode.")] + public async Task When_AJsonElementIsRendered_Then_ItsFieldsAreShown() + { + using var document = JsonDocument.Parse("""{"id":7,"name":"from-element"}"""); + + var output = await RenderAsync(document.RootElement).ConfigureAwait(false); + + output.Should().MatchRegex(@"id:\s+7"); + output.Should().MatchRegex(@"name:\s+""from-element"""); + AssertNoClrMembers(output); + } + + [TestMethod] + [Description("Control characters in a JSON string, such as an ANSI escape, must reach the terminal escaped, not raw.")] + public async Task When_AJsonStringCarriesControlCharacters_Then_TheyAreEscaped() + { + var output = await RenderRawAsync(new JsonObject { ["note"] = "red\u001b[31malert" }).ConfigureAwait(false); + + output.Should().NotContain("\u001b[31m", "a raw escape would let the payload drive the operator's terminal"); + output.Should().Contain(@"\u001B[31m"); + } + + [TestMethod] + [Description("Rendering reads the payload; it must not change it.")] + public async Task When_AJsonObjectIsRendered_Then_ThePayloadIsUnchanged() + { + var payload = new JsonObject { ["id"] = 1, ["nested"] = new JsonObject { ["x"] = 2 } }; + var before = payload.ToJsonString(); + + await RenderAsync(payload).ConfigureAwait(false); + + payload.ToJsonString().Should().Be(before); + } + + [TestMethod] + [Description("A JsonValue can wrap an arbitrary CLR object. Rendering it must not throw where --json succeeds.")] + public async Task When_AJsonValueWrapsAClrObject_Then_ItRenders() + { + var output = await RenderAsync(new JsonObject { ["owner"] = JsonValue.Create(new Owner("octo")) }).ConfigureAwait(false); + + output.Should().Contain("""{"Login":"octo"}"""); + } + + [TestMethod] + [Description("A JSON array whose items are all null still renders each as null: there is no first value to recognize it by, so it cannot go through the generic collection path.")] + public async Task When_AJsonArrayOfNullsIsRendered_Then_EachItemReadsNull() + { + var output = await RenderAsync(new JsonArray(null, null)).ConfigureAwait(false); + + output.Split(Environment.NewLine).Should().Equal("null", "null"); + } + + [TestMethod] + [Description("A JsonNode held by an ordinary result object's property renders as a compact JSON literal, not as that node's CLR members.")] + public async Task When_AnObjectHasAJsonProperty_Then_ItRendersAsALiteral() + { + var output = await RenderAsync(new Holder("h1", new JsonObject { ["x"] = 1 })).ConfigureAwait(false); + + output.Should().Contain("""{"x":1}"""); + AssertNoClrMembers(output); + } + + [TestMethod] + [Description("Bidirectional format characters are escaped too: left raw they reorder what the terminal displays.")] + public async Task When_AJsonStringCarriesBidiControls_Then_TheyAreEscaped() + { + var output = await RenderRawAsync(new JsonObject { ["file\u202Egpj"] = "admin\u202Eexe.txt" }).ConfigureAwait(false); + + output.Should().NotContain("\u202E", "a raw override would reorder what the terminal displays"); + output.Should().Contain(@"\u202E"); + } + + [TestMethod] + [Description("A continuation page carries its own JSON header: its rows' keys can come in another order, or be other keys, than the first page's, whose headings would then mislabel its values.")] + public async Task When_AContinuationPageHasOtherKeys_Then_ItCarriesItsOwnHeader() + { + var page = new ReplPage( + [new JsonObject { ["name"] = "b", ["id"] = 2 }], + new ReplPageInfo(Cursor: "1", NextCursor: null, TotalCount: null, PageSize: 1)); + + var rendered = await new SpectreHumanOutputTransformer() + .RenderPageAsync(page, CancellationToken.None) + .ConfigureAwait(false); + + AnsiStyling().Replace(rendered.Text, string.Empty).Should().MatchRegex(@"name\s+id"); + rendered.Layout.HeaderLineCount.Should().Be(1); + rendered.Layout.Columns.Should().Equal("name", "id"); + } + + [TestMethod] + [Description("Rows that are all empty objects have no keys to make columns of: each reads {} rather than being dropped from a table with no columns.")] + public async Task When_EveryRowIsAnEmptyObject_Then_EachReadsAsAnEmptyObject() + { + var output = await RenderAsync(new JsonArray(new JsonObject(), new JsonObject())).ConfigureAwait(false); + + output.Split(Environment.NewLine).Should().Equal("{}", "{}"); + } + + [TestMethod] + [Description("Cells are looked up ordinally, the way the columns were collected: a case-insensitive row must not also fill the 'name' column from its 'Name' key.")] + public async Task When_ARowIsCaseInsensitive_Then_ItsCellsStillMatchKeysOrdinally() + { + var caseInsensitive = new JsonObject(new JsonNodeOptions { PropertyNameCaseInsensitive = true }) { ["Name"] = "upper" }; + + var output = await RenderAsync(new JsonArray(caseInsensitive, new JsonObject { ["name"] = "lower" })).ConfigureAwait(false); + + output.Split("\"upper\"").Should().HaveCount(2, "the value belongs to its own column only"); + } + + [TestMethod] + [Description("A page of JSON nulls is recognized from its declared item type: with no non-null item to go on, it would otherwise read as having no results.")] + public async Task When_APageOfJsonNullsIsRendered_Then_EachItemReadsNull() + { + var page = new ReplPage( + [null, null], + new ReplPageInfo(Cursor: null, NextCursor: null, TotalCount: null, PageSize: 2)); + + var output = await RenderAsync(page).ConfigureAwait(false); + + output.Split(Environment.NewLine).Should().Equal("null", "null"); + } + + [TestMethod] + [Description("Through the pager, a continuation page with the same keys at other widths repeats the pinned header, so it adds its data row only, with or without ANSI: a plain Spectre header has neither a separator nor styling to be recognized by.")] + [DataRow(AnsiMode.Always)] + [DataRow(AnsiMode.Never)] + public async Task When_ThePagerAppendsAPageWithTheSameKeys_Then_OnlyItsRowIsAdded(AnsiMode ansiMode) + { + var session = await PageThroughAsync( + CreateTransformer(ansiMode), + new JsonObject { ["id"] = 1, ["name"] = "a" }, + new JsonObject { ["id"] = 22, ["name"] = "bbbbbb" }).ConfigureAwait(false); + + session.HeaderLines.Should().ContainSingle("the first page's header is pinned"); + session.Lines.Should().HaveCount(2, "the first page's row and the continuation's row, with no repeated header"); + AnsiStyling().Replace(session.Lines[1], string.Empty).Should().Contain("\"bbbbbb\""); + } + + [TestMethod] + [Description("A header label too long for a narrow table is truncated on its one line rather than wrapped onto a second: the pager is told the header is one line tall, and a wrapped half would show up as a row.")] + public async Task When_AHeaderLabelDoesNotFit_Then_ItStaysOnOneLine() + { + var label = "a rather long column label"; + var session = await PageThroughAsync( + CreateTransformer(AnsiMode.Never, width: 20), + new JsonObject { ["id"] = 1, [label] = "x" }, + new JsonObject { ["id"] = 2, [label] = "y" }).ConfigureAwait(false); + + session.HeaderLines.Should().ContainSingle(); + session.Lines.Should().HaveCount(2, "the two rows; no part of the header is left among them"); + } + + [TestMethod] + [Description("A result whose details are a page ends with that page's footer, whole on its last line even when it outruns the width: laid out again as Spectre text, it would wrap, and the declared one-line footer would leave half of it behind.")] + public async Task When_AResultCarriesAPageWithALongCursor_Then_ItsFooterIsWholeAndLast() + { + var page = new ReplPage( + [new JsonObject { ["id"] = 1, ["note"] = new string('x', 30) }], + new ReplPageInfo(Cursor: null, NextCursor: new string('c', 120), TotalCount: 5, PageSize: 1)); + + var rendered = await CreateTransformer(AnsiMode.Always, width: 40) + .RenderAsync(Results.Success("Found", page), CancellationToken.None) + .ConfigureAwait(false); + + var lines = rendered.Text.ReplaceLineEndings("\n").Split('\n'); + AnsiStyling().Replace(lines[^1], string.Empty).Should().StartWith("Showing 1 of 5.").And.EndWith(new string('c', 120) + "."); + rendered.Layout.HeaderLineCount.Should().Be(0); + rendered.Layout.FooterLineCount.Should().Be(1); + } + + [TestMethod] + [Description("A type's table whose header fits keeps its labels whole and wraps its data: labels are one line each, but are truncated only when the header itself does not fit.")] + public async Task When_ATypeTableIsNarrow_Then_ItsLabelsStayWhole() + { + var output = await CreateTransformer(AnsiMode.Never, width: 40) + .TransformAsync(new[] { new Described("Ada", "2026-09-24", new string('x', 60)) }, CancellationToken.None) + .ConfigureAwait(false); + + var header = output.Split(Environment.NewLine)[0]; + header.Should().Contain("Display Name").And.Contain("Created At"); + } + + [TestMethod] + [Description("Through the pager, with Spectre's own styled header: keys 'first' / 'last name' and 'first last' / 'name' share their words but are other columns, so the continuation keeps its header.")] + public async Task When_ThePagerAppendsAPageWhoseKeysRegroupTheWords_Then_ItsRowsKeepTheirOwnHeader() + { + var session = await PageThroughAsync( + CreateTransformer(AnsiMode.Always), + new JsonObject { ["first"] = "a", ["last name"] = "b" }, + new JsonObject { ["first last"] = "c", ["name"] = "d" }).ConfigureAwait(false); + + var lines = session.Lines.Select(line => AnsiStyling().Replace(line, string.Empty)).ToList(); + var row = lines.FindIndex(line => line.Contains("\"d\"", StringComparison.Ordinal)); + row.Should().BePositive(); + lines.Take(row).Should().Contain(line => line.Contains("first last", StringComparison.Ordinal)); + } + + [TestMethod] + [Description("JSON details render as the JSON result itself would, also when their lines fill the width: composed as a pre-rendered string, their escape sequences counted as columns and the lines wrapped again.")] + public async Task When_AResultCarriesWideJsonDetails_Then_TheyRenderAsTheResultWould() + { + var transformer = new SpectreHumanOutputTransformer( + () => new HumanRenderSettings(Width: 80, UseAnsi: true, Palette: new DefaultAnsiPaletteProvider().Create(ThemeMode.Dark)), + new OutputOptions { AnsiMode = AnsiMode.Always }); + JsonArray Rows() => new( + new JsonObject { ["id"] = 1, ["description"] = new string('x', 30), ["owner"] = "someone@example.com" }, + new JsonObject { ["id"] = 2, ["description"] = new string('y', 30), ["owner"] = "other@example.com" }); + + var direct = AnsiStyling().Replace(await transformer.TransformAsync(Rows(), CancellationToken.None).ConfigureAwait(false), string.Empty); + var details = AnsiStyling().Replace(await transformer.TransformAsync(Results.Success("x", Rows()), CancellationToken.None).ConfigureAwait(false), string.Empty); + + details.Should().EndWith(direct); + } + + [TestMethod] + [Description("JSON carried as an IReplResult's details renders like a JSON result, as the human transformer renders it, rather than as one compact literal.")] + public async Task When_AResultCarriesJsonDetails_Then_TheFieldsAreShown() + { + var output = await RenderAsync(Results.Success("done", new JsonObject { ["code"] = 42, ["state"] = "ok" })).ConfigureAwait(false); + + output.Should().MatchRegex(@"code:\s+42"); + output.Should().MatchRegex(@"state:\s+""ok"""); + AssertNoClrMembers(output); + } + + // ANSI is forced one way or the other, so these tests do not depend on whether the console running them + // supports it. + private static SpectreHumanOutputTransformer CreateTransformer(AnsiMode ansiMode, int width = 120) => + new( + () => new HumanRenderSettings( + Width: width, + UseAnsi: ansiMode == AnsiMode.Always, + Palette: new DefaultAnsiPaletteProvider().Create(ThemeMode.Dark)), + new OutputOptions { AnsiMode = ansiMode }); + + // Drives single-row pages through a PagerSession the way the pager does, each with the layout its transformer + // declared. + private static async Task PageThroughAsync( + SpectreHumanOutputTransformer transformer, + params JsonObject[] rows) + { + var initial = await transformer.RenderPageAsync(SingleRowPage(rows[0]), CancellationToken.None).ConfigureAwait(false); + var session = new PagerSession(initial.Text, hasMorePayload: rows.Length > 1, maxBufferedLines: 100, initial.Layout); + for (var i = 1; i < rows.Length; i++) + { + var next = await transformer.RenderPageAsync(SingleRowPage(rows[i]), CancellationToken.None).ConfigureAwait(false); + session.Append(next.Text, hasMorePayload: i < rows.Length - 1, containsPresentationChrome: false, next.Layout); + } + + return session; + } + + private sealed record Described( + [property: System.ComponentModel.DataAnnotations.Display(Name = "Display Name")] string Name, + [property: System.ComponentModel.DataAnnotations.Display(Name = "Created At")] string CreatedAt, + string Description); + + private static ReplPage SingleRowPage(JsonObject row) => + new([row], new ReplPageInfo(Cursor: null, NextCursor: null, TotalCount: null, PageSize: 1)); + + [TestMethod] + [Description("A property declared as JSON holding null is a JSON null, as --json writes it: it reads null, not an empty value.")] + public async Task When_AJsonTypedPropertyIsNull_Then_ItReadsNull() + { + var output = await RenderAsync(new NullableHolder("h1", Payload: null)).ConfigureAwait(false); + + output.Should().MatchRegex(@"Payload\W+null"); + } + + [TestMethod] + [Description("Line and paragraph separators (U+2028, U+2029) in a JSON key or string are escaped too: the pager breaks lines at them, so left raw they would split a one-line header or row.")] + public async Task When_AJsonStringCarriesLineSeparators_Then_TheyAreEscaped() + { + var output = await RenderRawAsync(new JsonArray(new JsonObject { ["a\u2028b"] = "c\u2029d" })).ConfigureAwait(false); + + output.Should().NotContain("\u2028").And.NotContain("\u2029"); + output.Should().Contain(@"a\u2028b").And.Contain(@"c\u2029d"); + } + + private sealed record NullableHolder(string Name, JsonNode? Payload); + + private sealed record Holder(string Name, JsonObject Payload); + + private sealed record Owner(string Login); + + // Spectre bolds labels with ANSI; the assertions are about the data, so that styling is stripped. The + // control-character test reads the raw output instead, since what reaches the terminal is its point. + private static async Task RenderAsync(object value) => + AnsiStyling().Replace(await RenderRawAsync(value).ConfigureAwait(false), string.Empty); + + private static async Task RenderRawAsync(object value) => + await new SpectreHumanOutputTransformer().TransformAsync(value, CancellationToken.None).ConfigureAwait(false); + + [GeneratedRegex(@"\u001b\[[0-9;]*m", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex AnsiStyling(); + + private static void AssertNoClrMembers(string output) + { + foreach (var member in ClrMembers) + { + output.Should().NotContain(member, "JSON data must not render the CLR members of its container type"); + } + } +} diff --git a/src/Repl.Tests/Given_HumanOutputJson.cs b/src/Repl.Tests/Given_HumanOutputJson.cs new file mode 100644 index 00000000..144568fc --- /dev/null +++ b/src/Repl.Tests/Given_HumanOutputJson.cs @@ -0,0 +1,490 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Repl.Tests; + +/// +/// A command that returns or is returning JSON data, so +/// human output shows the JSON keys and values — not the CLR members (Options, Parent, +/// Root, Count) that reflection finds on those types. +/// +[TestClass] +public sealed class Given_HumanOutputJson +{ + private static readonly string[] ClrMembers = ["Options", "Parent", "Root", "Count", "ValueKind"]; + + [TestMethod] + [Description("The issue's own repro: a JsonObject result renders one line per JSON field, values as JSON literals, and none of JsonObject's CLR members.")] + public async Task When_AJsonObjectIsRendered_Then_ItsFieldsAreShown() + { + var output = await RenderAsync(new JsonObject { ["id"] = 1, ["name"] = "Example", ["active"] = true }); + + output.Should().MatchRegex(@"id\s*:\s*1"); + output.Should().MatchRegex(@"name\s*:\s*""Example"""); + output.Should().MatchRegex(@"active\s*:\s*true"); + AssertNoClrMembers(output); + } + + [TestMethod] + [Description("Paged JsonObject rows render as a table whose columns are the rows' JSON keys, in first-seen order, with a key missing from one row left empty rather than dropping the row.")] + public async Task When_APageOfJsonObjectsIsRendered_Then_TheKeysBecomeColumns() + { + var page = new ReplPage( + [ + new JsonObject { ["id"] = 1, ["name"] = "first" }, + new JsonObject { ["id"] = 2, ["name"] = "second", ["extra"] = "only-here" }, + ], + new ReplPageInfo(Cursor: null, NextCursor: null, TotalCount: 2, PageSize: 2)); + + var output = await RenderAsync(page); + + var header = output.Split(Environment.NewLine)[0]; + header.Should().MatchRegex(@"id\s+name\s+extra"); + output.Should().Contain("\"first\"").And.Contain("\"second\"").And.Contain("\"only-here\""); + AssertNoClrMembers(output); + } + + [TestMethod] + [Description("Nested objects and arrays stay visible as compact JSON, and an explicit JSON null reads as null rather than as an empty value.")] + public async Task When_AJsonObjectHasNestedValuesAndNulls_Then_TheyArePreserved() + { + var output = await RenderAsync(new JsonObject + { + ["owner"] = new JsonObject { ["login"] = "octo" }, + ["tags"] = new JsonArray("a", "b"), + ["deleted"] = null, + }); + + output.Should().Contain("""{"login":"octo"}"""); + output.Should().Contain("""["a","b"]"""); + output.Should().MatchRegex(@"deleted\s*:\s*null"); + AssertNoClrMembers(output); + } + + [TestMethod] + [Description("A JsonElement is JSON data too, and renders the same way as the equivalent JsonNode.")] + public async Task When_AJsonElementIsRendered_Then_ItsFieldsAreShown() + { + using var document = JsonDocument.Parse("""{"id":7,"name":"from-element"}"""); + + var output = await RenderAsync(document.RootElement); + + output.Should().MatchRegex(@"id\s*:\s*7"); + output.Should().MatchRegex(@"name\s*:\s*""from-element"""); + AssertNoClrMembers(output); + } + + [TestMethod] + [Description("A JSON string is untrusted data: control characters in it, such as an ANSI escape, must reach the terminal escaped, not raw. Non-ASCII text stays readable.")] + public async Task When_AJsonStringCarriesControlCharacters_Then_TheyAreEscaped() + { + var output = await RenderAsync(new JsonObject { ["note"] = "red\u001b[31malert", ["city"] = "Montréal" }); + + output.Should().NotContain("\u001b", "a raw escape would let the payload drive the operator's terminal"); + output.Should().Contain("Montréal"); + } + + [TestMethod] + [Description("Rendering reads the payload; it must not change it.")] + public async Task When_AJsonObjectIsRendered_Then_ThePayloadIsUnchanged() + { + var payload = new JsonObject { ["id"] = 1, ["nested"] = new JsonObject { ["x"] = 2 } }; + var before = payload.ToJsonString(); + + await RenderAsync(payload); + + payload.ToJsonString().Should().Be(before); + } + + [TestMethod] + [Description("A key missing from one row leaves that row's cell empty: it must not read null, and the other cells must not shift into it.")] + public async Task When_ARowLacksAKey_Then_ItsCellIsEmpty() + { + var page = new ReplPage( + [ + new JsonObject { ["id"] = 1, ["name"] = "first" }, + new JsonObject { ["id"] = 2, ["name"] = "second", ["extra"] = "only-here" }, + ], + new ReplPageInfo(Cursor: null, NextCursor: null, TotalCount: 2, PageSize: 2)); + + var output = await RenderAsync(page); + + var firstRow = output.Split(Environment.NewLine).Single(line => line.Contains("\"first\"", StringComparison.Ordinal)); + firstRow.TrimEnd().Should().EndWith("\"first\"", "the extra column is empty for the row that does not have it"); + } + + [TestMethod] + [Description("A top-level JSON array of scalars renders one JSON literal per line, and its JSON nulls read as null — also when every item is null, where there is no first value to recognize the array by.")] + public async Task When_AJsonArrayOfScalarsIsRendered_Then_EachItemIsALiteral() + { + (await RenderAsync(new JsonArray(1, "two", null))).Split(Environment.NewLine) + .Should().Equal("1", "\"two\"", "null"); + (await RenderAsync(new JsonArray(null, null))).Split(Environment.NewLine) + .Should().Equal("null", "null"); + } + + [TestMethod] + [Description("A page of JsonElement rows renders like a page of JsonObject rows: the issue names JsonElement explicitly.")] + public async Task When_APageOfJsonElementsIsRendered_Then_TheKeysBecomeColumns() + { + using var document = JsonDocument.Parse("""[{"id":1,"name":"first"},{"id":2,"name":"second"}]"""); + var page = new ReplPage( + [.. document.RootElement.EnumerateArray()], + new ReplPageInfo(Cursor: null, NextCursor: null, TotalCount: 2, PageSize: 2)); + + var output = await RenderAsync(page); + + output.Split(Environment.NewLine)[0].Should().MatchRegex(@"id\s+name"); + output.Should().Contain("\"second\""); + AssertNoClrMembers(output); + } + + [TestMethod] + [Description("A JsonNode held by an ordinary result object's property renders as a compact JSON literal, not as that node's CLR members.")] + public async Task When_AnObjectHasAJsonProperty_Then_ItRendersAsALiteral() + { + var output = await RenderAsync(new Holder("h1", new JsonObject { ["x"] = 1 })); + + output.Should().MatchRegex(@"Payload\s*:\s*\{""x"":1\}"); + AssertNoClrMembers(output); + } + + [TestMethod] + [Description("A JsonValue can wrap an arbitrary CLR object. Rendering it must not throw where --json succeeds: serializing through options that carry no type resolver does exactly that.")] + public async Task When_AJsonValueWrapsAClrObject_Then_ItRenders() + { + var output = await RenderAsync(new JsonObject { ["owner"] = JsonValue.Create(new Owner("octo")) }); + + output.Should().Contain("""{"Login":"octo"}"""); + } + + [TestMethod] + [Description("JSON carried as an IReplResult's details renders as JSON data too, not as JsonNode's CLR members.")] + public async Task When_AResultCarriesJsonDetails_Then_TheFieldsAreShown() + { + var output = await RenderAsync(Results.Success("done", new JsonObject { ["code"] = 42 })); + + output.Should().MatchRegex(@"code\s*:\s*42"); + AssertNoClrMembers(output); + } + + [TestMethod] + [Description("Bidirectional and other format characters (U+202E, U+2066 to U+2069, U+200B and others) are escaped too: left raw they reorder or hide what the terminal shows, so a value could display as something it is not.")] + public async Task When_AJsonStringCarriesBidiControls_Then_TheyAreEscaped() + { + var output = await RenderAsync(new JsonObject { ["file\u202Egpj"] = "admin\u202Eexe.txt" }); + + output.Should().NotContain("\u202E", "a raw override would reorder what the terminal displays"); + output.Should().Contain(@"\u202E"); + } + + [TestMethod] + [Description("A continuation page carries its own JSON header: its rows' keys can come in another order, or be other keys, than the first page's, whose headings would then mislabel its values.")] + public async Task When_AContinuationPageHasOtherKeys_Then_ItCarriesItsOwnHeader() + { + var page = new ReplPage( + [new JsonObject { ["name"] = "b", ["id"] = 2 }], + new ReplPageInfo(Cursor: "1", NextCursor: null, TotalCount: null, PageSize: 1)); + + var rendered = await CreateTransformer().RenderPageAsync(page, CancellationToken.None); + + rendered.Text.Split(Environment.NewLine)[0].Should().MatchRegex(@"^name\s+id\s*$"); + rendered.Layout.HeaderLineCount.Should().Be(2, "the header line and its separator"); + rendered.Layout.Columns.Should().Equal("name", "id"); + } + + [TestMethod] + [Description("Rows that are all empty objects have no keys to make columns of: each reads {} rather than being dropped from a table with no columns.")] + public async Task When_EveryRowIsAnEmptyObject_Then_EachReadsAsAnEmptyObject() + { + (await RenderAsync(new JsonArray(new JsonObject(), new JsonObject()))).Split(Environment.NewLine) + .Should().Equal("{}", "{}"); + } + + [TestMethod] + [Description("Cells are looked up ordinally, the way the columns were collected: a case-insensitive row must not also fill the 'name' column from its 'Name' key.")] + public async Task When_ARowIsCaseInsensitive_Then_ItsCellsStillMatchKeysOrdinally() + { + var caseInsensitive = new JsonObject(new JsonNodeOptions { PropertyNameCaseInsensitive = true }) { ["Name"] = "upper" }; + + var output = await RenderAsync(new JsonArray(caseInsensitive, new JsonObject { ["name"] = "lower" })); + + var lines = output.Split(Environment.NewLine); + lines[0].Should().MatchRegex(@"^Name\s+name\s*$"); + var upperRow = lines.Single(line => line.Contains("\"upper\"", StringComparison.Ordinal)); + upperRow.TrimEnd().Should().Be("\"upper\"", "its value sits under Name only, leaving the name cell empty"); + var lowerRow = lines.Single(line => line.Contains("\"lower\"", StringComparison.Ordinal)); + lowerRow.Should().MatchRegex(@"^\s+""lower""\s*$", "its value sits under name only, leaving the Name cell empty"); + } + + [TestMethod] + [Description("A page of JSON nulls is recognized from its declared item type: with no non-null item to go on, it would otherwise render blank rows.")] + public async Task When_APageOfJsonNullsIsRendered_Then_EachItemReadsNull() + { + var page = new ReplPage( + [null, null], + new ReplPageInfo(Cursor: null, NextCursor: null, TotalCount: null, PageSize: 2)); + + (await RenderAsync(page)).Split(Environment.NewLine).Should().Equal("null", "null"); + } + + [TestMethod] + [Description("A page of nullable JsonElement is JSON too: its declared item type is Nullable, not JsonElement itself.")] + public async Task When_APageOfNullableJsonElementNullsIsRendered_Then_EachItemReadsNull() + { + var page = new ReplPage( + [null, null], + new ReplPageInfo(Cursor: null, NextCursor: null, TotalCount: null, PageSize: 2)); + + (await RenderAsync(page)).Split(Environment.NewLine).Should().Equal("null", "null"); + } + + [TestMethod] + [Description("A JSON null among object rows, as a JsonElement or a CLR null, makes every row a literal: as an empty table row it would read as no row, and as the last one it would vanish with the payload's trailing blank line.")] + public async Task When_APageOfJsonObjectsHasANullRow_Then_EveryRowReadsAsALiteral() + { + using var document = JsonDocument.Parse("""[{"id":1},null]"""); + var elements = new ReplPage( + [.. document.RootElement.EnumerateArray()], + new ReplPageInfo(Cursor: null, NextCursor: null, TotalCount: null, PageSize: 2)); + var nodes = new ReplPage( + [new JsonObject { ["id"] = 1 }, null], + new ReplPageInfo(Cursor: null, NextCursor: null, TotalCount: null, PageSize: 2)); + + (await RenderAsync(elements)).Split(Environment.NewLine).Should().Equal("""{"id":1}""", "null"); + (await RenderAsync(nodes)).Split(Environment.NewLine).Should().Equal("""{"id":1}""", "null"); + } + + [TestMethod] + [Description("An empty object among keyed rows renders as blank as a null would, so it makes every row a literal too.")] + public async Task When_APageOfJsonObjectsHasAnEmptyObject_Then_EveryRowReadsAsALiteral() + { + var output = await RenderAsync(new JsonArray(new JsonObject { ["id"] = 1 }, new JsonObject())); + + output.Split(Environment.NewLine).Should().Equal("""{"id":1}""", "{}"); + } + + [TestMethod] + [Description("Through the pager, keys A, then B, then A again: each page shows its header, since the one in view before it names other columns.")] + public async Task When_ThePagerAppendsKeysThatSwitchBack_Then_EachPageShowsItsHeader() + { + var session = await PageThroughAsync( + CreateTransformer(), + SingleRowPage(new JsonObject { ["id"] = 1, ["name"] = "a" }), + SingleRowPage(new JsonObject { ["name"] = "b", ["id"] = 2 }), + SingleRowPage(new JsonObject { ["id"] = 3, ["name"] = "c" })); + + session.Lines.Should().HaveCount(7, "the first row, then a header, its separator and a row for each later page"); + session.Lines[4].Should().MatchRegex(@"^id\s+name\s*$"); + } + + [TestMethod] + [Description("Through the pager, a first page with no header pins none; the next table page shows its header, and the one after, with the same columns, adds its row only.")] + public async Task When_TheFirstPageHasNoHeader_Then_TheFirstTablePageShowsItOnce() + { + var session = await PageThroughAsync( + CreateTransformer(), + new ReplPage([null], new ReplPageInfo(Cursor: null, NextCursor: null, TotalCount: null, PageSize: 1)), + SingleRowPage(new JsonObject { ["id"] = 1 }), + SingleRowPage(new JsonObject { ["id"] = 2 })); + + session.HeaderLines.Should().BeEmpty(); + session.Lines.Should().Equal("null", "id", "--", "1", "2"); + } + + [TestMethod] + [Description("Through the pager, a page of rows of another type shows its own header: a type's table renders its header on every page, and the pager drops only a repeat.")] + public async Task When_ThePagerAppendsRowsOfAnotherType_Then_TheirHeaderIsShown() + { + var session = await PageThroughAsync( + CreateTransformer(), + new ReplPage([new Holder("h1", new JsonObject())], new ReplPageInfo(Cursor: null, NextCursor: null, TotalCount: null, PageSize: 1)), + new ReplPage([new Holder("h2", new JsonObject())], new ReplPageInfo(Cursor: null, NextCursor: null, TotalCount: null, PageSize: 1)), + new ReplPage([new Owner("octo")], new ReplPageInfo(Cursor: null, NextCursor: null, TotalCount: null, PageSize: 1))); + + session.Lines.Should().HaveCount(5, "the two holders' rows, then the owner's header, separator and row"); + session.Lines[2].Should().MatchRegex(@"^Login\s*$"); + } + + [TestMethod] + [Description("A line separator (U+2028) in a display name breaks the line for the pager as a line feed does, so it reads as a space too.")] + public async Task When_ADisplayNameHasALineSeparator_Then_TheHeaderStaysOnOneLine() + { + var rendered = await CreateTransformer().RenderAsync(new[] { new Separated("now") }, CancellationToken.None); + + var session = new PagerSession(rendered.Text, hasMorePayload: false, maxBufferedLines: 100, rendered.Layout); + session.HeaderLines.Should().HaveCount(2, "the header line and its separator"); + session.HeaderLines[0].Should().MatchRegex("^Created At *$"); + session.Lines.Should().ContainSingle(); + } + + [TestMethod] + [Description("A result whose details are a page declares that page's footer, its last line, and no header: the message comes first.")] + public async Task When_AResultCarriesAPage_Then_ItsFooterIsDeclaredAndLast() + { + var page = new ReplPage( + [new JsonObject { ["id"] = 1 }], + new ReplPageInfo(Cursor: null, NextCursor: "2", TotalCount: 5, PageSize: 1)); + + var rendered = await CreateTransformer().RenderAsync(Results.Success("Found", page), CancellationToken.None); + + rendered.Text.Split(Environment.NewLine)[^1].Should().StartWith("Showing 1 of 5."); + rendered.Layout.HeaderLineCount.Should().Be(0); + rendered.Layout.FooterLineCount.Should().Be(1); + } + + [TestMethod] + [Description("A display name with a line break still makes a one-line header: the declared height must match what is rendered.")] + public async Task When_ADisplayNameHasALineBreak_Then_TheHeaderStaysOnOneLine() + { + var rendered = await CreateTransformer().RenderAsync(new[] { new Stamped("now") }, CancellationToken.None); + + // Split the way the pager splits, at any line break, lone ones included. + var lines = rendered.Text.ReplaceLineEndings("\n").Split('\n'); + lines.Should().HaveCount(3, "the header, its separator and the row"); + lines[0].Should().MatchRegex("^Created At *$"); + rendered.Layout.HeaderLineCount.Should().Be(2); + } + + [TestMethod] + [Description("Through the pager, a page whose last row is a JSON null keeps that row: it reads null rather than being trimmed as a trailing blank line.")] + public async Task When_ThePagerReceivesAPageEndingInANullRow_Then_TheRowIsKept() + { + var page = new ReplPage( + [new JsonObject { ["id"] = 1 }, null], + new ReplPageInfo(Cursor: null, NextCursor: null, TotalCount: null, PageSize: 2)); + + var session = await PageThroughAsync(CreateTransformer(), page, SingleRowPage(new JsonObject { ["id"] = 2 })); + + session.Lines.Should().Contain("null"); + } + + [TestMethod] + [Description("Through the pager, a column named 1 holding 1 renders its header and its row alike; the row is data, and must not be dropped as a repeated header.")] + public async Task When_ARowReadsLikeTheHeader_Then_ThePagerKeepsIt() + { + var session = await PageThroughAsync( + CreateTransformer(), + SingleRowPage(new JsonObject { ["1"] = 1 }), + SingleRowPage(new JsonObject { ["1"] = 1 })); + + session.Lines.Should().Equal("1", "1"); + } + + [TestMethod] + [Description("Through the ANSI pager, keys 'a b' / 'c' and 'a' / 'b c' render headers that read alike but name other columns: the continuation keeps its own.")] + public async Task When_TheAnsiPagerAppendsKeysThatRegroupTheirSpaces_Then_TheContinuationKeepsItsHeader() + { + var session = await PageThroughAsync( + CreateTransformer(useAnsi: true), + SingleRowPage(new JsonObject { ["a b"] = 1, ["c"] = 2 }), + SingleRowPage(new JsonObject { ["a"] = 3, ["b c"] = 4 })); + + session.Lines.Should().HaveCount(3, "the first row, then the continuation's own header and its row"); + } + + [TestMethod] + [Description("A property declared as JSON holding null is a JSON null, as --json writes it: it reads null, not an empty value, both as a field and as a table cell.")] + public async Task When_AJsonTypedPropertyIsNull_Then_ItReadsNull() + { + (await RenderAsync(new NullableHolder("h1", Payload: null))).Should().MatchRegex(@"Payload\s*:\s*null"); + + var page = new ReplPage( + [new NullableHolder("a", Payload: null), new NullableHolder("b", new JsonObject { ["x"] = 1 })], + new ReplPageInfo(Cursor: null, NextCursor: null, TotalCount: null, PageSize: 2)); + var rowA = (await RenderAsync(page)).Split(Environment.NewLine).Single(line => line.StartsWith('a')); + rowA.TrimEnd().Should().EndWith("null"); + } + + [TestMethod] + [Description("An explicit NullDisplayText still wins over the JSON null of a property declared as JSON.")] + public async Task When_AJsonTypedPropertyHasANullDisplayText_Then_ItIsUsed() + { + (await RenderAsync(new DisplayedHolder(Payload: null))).Should().MatchRegex(@"Payload\s*:\s*\(none\)"); + } + + [TestMethod] + [Description("With ANSI on, the table header has no separator line and a palette style; the pager must still pin it, or every JSON page, which carries its own header, would repeat it.")] + public async Task When_AnAnsiPagerAppendsAPageWithTheSameKeys_Then_OnlyItsRowIsAdded() + { + var session = await PageThroughAsync( + CreateTransformer(useAnsi: true), + SingleRowPage(new JsonObject { ["id"] = 1, ["name"] = "a" }), + SingleRowPage(new JsonObject { ["id"] = 22, ["name"] = "bbbbbb" })); + + session.HeaderLines.Should().ContainSingle("the styled header line is the pinned header"); + session.Lines.Should().HaveCount(2, "the first page's row and the continuation's row, with no repeated header"); + } + + [TestMethod] + [Description("Line and paragraph separators (U+2028, U+2029) in a JSON key or string are escaped too: the pager breaks lines at them, so left raw they would split a one-line header or row.")] + public async Task When_AJsonStringCarriesLineSeparators_Then_TheyAreEscaped() + { + var output = await RenderAsync(new JsonArray(new JsonObject { ["a\u2028b"] = "c\u2029d" })); + + output.Should().NotContain("\u2028").And.NotContain("\u2029"); + output.Should().Contain(@"a\u2028b").And.Contain(@"c\u2029d"); + } + + [TestMethod] + [Description("A JsonElement may repeat a property name, which a JsonObject cannot hold: the last value is the one shown, as JavaScript reads it, rather than rendering failing.")] + public async Task When_AJsonElementRepeatsAPropertyName_Then_TheLastValueIsShown() + { + using var document = JsonDocument.Parse("""{"id":1,"id":2,"nested":{"x":1,"x":3}}"""); + using var rows = JsonDocument.Parse("""[{"id":1,"id":2}]"""); + + var output = await RenderAsync(document.RootElement); + var table = await RenderAsync(rows.RootElement); + + output.Should().MatchRegex(@"id\s*:\s*2"); + output.Should().Contain("""{"x":3}"""); + table.Split(Environment.NewLine)[^1].Trim().Should().Be("2"); + } + + private sealed record NullableHolder(string Name, JsonNode? Payload); + + private sealed record DisplayedHolder( + [property: System.ComponentModel.DataAnnotations.DisplayFormat(NullDisplayText = "(none)")] JsonNode? Payload); + + private sealed record Holder(string Name, JsonObject Payload); + + private sealed record Owner(string Login); + + private sealed record Separated([property: System.ComponentModel.DataAnnotations.Display(Name = "Created\u2028At")] string When); + + private sealed record Stamped([property: System.ComponentModel.DataAnnotations.Display(Name = "Created\nAt")] string When); + + private static ReplPage SingleRowPage(JsonObject row) => + new([row], new ReplPageInfo(Cursor: null, NextCursor: null, TotalCount: null, PageSize: 1)); + + private static HumanOutputTransformer CreateTransformer(bool useAnsi = false) => + new(() => new HumanRenderSettings( + Width: 120, + UseAnsi: useAnsi, + Palette: new DefaultAnsiPaletteProvider().Create(ThemeMode.Dark))); + + // Drives pages through a PagerSession the way the pager does, each with the layout its transformer declared. + private static async Task PageThroughAsync(HumanOutputTransformer transformer, params IReplPage[] pages) + { + var initial = await transformer.RenderPageAsync(pages[0], CancellationToken.None).ConfigureAwait(false); + var session = new PagerSession(initial.Text, hasMorePayload: pages.Length > 1, maxBufferedLines: 100, initial.Layout); + for (var i = 1; i < pages.Length; i++) + { + var next = await transformer.RenderPageAsync(pages[i], CancellationToken.None).ConfigureAwait(false); + session.Append(next.Text, hasMorePayload: i < pages.Length - 1, containsPresentationChrome: false, next.Layout); + } + + return session; + } + + private static async Task RenderAsync(object value) => + await CreateTransformer().TransformAsync(value, CancellationToken.None).ConfigureAwait(false); + + private static void AssertNoClrMembers(string output) + { + foreach (var member in ClrMembers) + { + output.Should().NotContain(member, "JSON data must not render the CLR members of its container type"); + } + } +} diff --git a/src/Repl.Tests/Given_ResultFlowOutputTransformer.cs b/src/Repl.Tests/Given_ResultFlowOutputTransformer.cs index c1c99a7b..8bc828ae 100644 --- a/src/Repl.Tests/Given_ResultFlowOutputTransformer.cs +++ b/src/Repl.Tests/Given_ResultFlowOutputTransformer.cs @@ -6,14 +6,10 @@ namespace Repl.Tests; public sealed class Given_ResultFlowOutputTransformer { [TestMethod] - [Description("Result-flow continuation payloads render table rows only so pagers do not receive repeated headers or page footers.")] - public async Task When_RenderingHumanContinuationPage_Then_HeaderAndFooterAreOmitted() + [Description("A page rendered for the pager carries its table header on every page, declared with the columns it names, and no footer: the pager, not the transformer, drops a header that repeats the previous page's columns.")] + public async Task When_RenderingAHumanPageForThePager_Then_ItsHeaderIsDeclaredAndItsFooterOmitted() { - var transformer = new HumanOutputTransformer( - () => new HumanRenderSettings( - Width: 120, - UseAnsi: false, - Palette: new DefaultAnsiPaletteProvider().Create(ThemeMode.Dark))); + var transformer = CreateTransformer(); var page = new ReplPage( [ new ActivityRow( @@ -35,29 +31,22 @@ public async Task When_RenderingHumanContinuationPage_Then_HeaderAndFooterAreOmi TotalCount: 250, PageSize: 2)); - var output = await ((IResultFlowOutputTransformer)transformer).TransformPageAsync( - page, - ResultFlowPageRenderMode.Continuation, - CancellationToken.None); + var rendered = await ((ILayoutDeclaringOutputTransformer)transformer).RenderPageAsync(page, CancellationToken.None); - output.Should().NotContain("#"); - output.Should().NotContain("---"); - output.Should().NotContain("Showing "); - output.Should().Contain("49"); - output.Should().Contain("identity batch 10 validated successfully"); - output.Should().Contain("50"); - output.Should().Contain("billing batch 10 queued successfully"); + var lines = rendered.Text.Split(Environment.NewLine); + lines.Should().HaveCount(4, "the header, its separator and the two rows"); + lines[0].Should().MatchRegex(@"^#\s+At\s+Area\s+Event\s+Summary"); + rendered.Text.Should().NotContain("Showing "); + rendered.Layout.HeaderLineCount.Should().Be(2); + rendered.Layout.Columns.Should().Equal("#", "At", "Area", "Event", "Summary"); + rendered.Layout.FooterLineCount.Should().Be(0); } [TestMethod] - [Description("Result-flow initial payloads keep the table header so the first page remains readable.")] - public async Task When_RenderingHumanInitialPage_Then_HeaderIsIncluded() + [Description("A page rendered on its own keeps the footer asking to rerun for more, and declares it, so the pager strips exactly that line rather than any line that reads like one.")] + public async Task When_RenderingAHumanPageOnItsOwn_Then_ItsFooterIsDeclared() { - var transformer = new HumanOutputTransformer( - () => new HumanRenderSettings( - Width: 120, - UseAnsi: false, - Palette: new DefaultAnsiPaletteProvider().Create(ThemeMode.Dark))); + var transformer = CreateTransformer(); var page = new ReplPage( [ new ActivityRow( @@ -73,16 +62,19 @@ public async Task When_RenderingHumanInitialPage_Then_HeaderIsIncluded() TotalCount: 250, PageSize: 1)); - var output = await ((IResultFlowOutputTransformer)transformer).TransformPageAsync( - page, - ResultFlowPageRenderMode.Initial, - CancellationToken.None); + var rendered = await ((ILayoutDeclaringOutputTransformer)transformer).RenderAsync(page, CancellationToken.None); - output.Should().Contain("#"); - output.Should().Contain("At"); - output.Should().NotContain("Showing "); + rendered.Text.Split(Environment.NewLine)[^1].Should().StartWith("Showing 1 of 250."); + rendered.Layout.HeaderLineCount.Should().Be(2); + rendered.Layout.FooterLineCount.Should().Be(1); } + private static HumanOutputTransformer CreateTransformer() => + new(() => new HumanRenderSettings( + Width: 120, + UseAnsi: false, + Palette: new DefaultAnsiPaletteProvider().Create(ThemeMode.Dark))); + [TestMethod] [Description("Human page footers never render unsafe cursor text directly.")] public async Task When_HumanPageFooterHasUnsafeCursor_Then_CursorIsNotRenderedVerbatim() diff --git a/src/Repl.Tests/Given_ResultFlowPager.cs b/src/Repl.Tests/Given_ResultFlowPager.cs index eae06bc3..3dddd4a5 100644 --- a/src/Repl.Tests/Given_ResultFlowPager.cs +++ b/src/Repl.Tests/Given_ResultFlowPager.cs @@ -1036,6 +1036,132 @@ public void When_HeaderContainsLoneEscape_Then_NormalizationStillDeduplicatesCon second.ContentLines.Should().Equal("two"); } + [TestMethod] + [Description("A declared header is dropped from a page that names the previous page's columns, whatever its text: a plain Spectre header has neither a separator nor styling to be recognized by.")] + public void When_ADeclaredHeaderNamesThePreviousColumns_Then_ItIsDropped() + { + var layout = Declared(1, "id", "name"); + var first = PagerPayloadParser.ParseDeclared(Lines("id name", "1 a"), header: null, previousLayout: null, layout); + var second = PagerPayloadParser.ParseDeclared(Lines("id name", "22 bb"), first.Header, layout, Declared(1, "id", "name")); + + first.Header.Lines.Should().Equal("id name"); + first.ContentLines.Should().Equal("1 a"); + second.ContentLines.Should().Equal("22 bb"); + } + + [TestMethod] + [Description("A declared header naming other columns is kept, even when its text reads the same: keys 'a b' / 'c' and 'a' / 'b c' render alike.")] + public void When_ADeclaredHeaderNamesOtherColumns_Then_ItIsKept() + { + var layout = Declared(1, "a b", "c"); + var first = PagerPayloadParser.ParseDeclared(Lines("a b c", "1 2"), header: null, previousLayout: null, layout); + var second = PagerPayloadParser.ParseDeclared(Lines("a b c", "3 4"), first.Header, layout, Declared(1, "a", "b c")); + + second.ContentLines.Should().Equal("a b c", "3 4"); + } + + [TestMethod] + [Description("A page is compared with the page before it, not with the pinned one: after columns A then B, a page back on A must show its header again, since the last header in view names B.")] + public void When_ColumnsGoBackToThePinnedOnes_Then_TheHeaderIsShownAgain() + { + var a = Declared(1, "id", "name"); + var b = Declared(1, "name", "id"); + var first = PagerPayloadParser.ParseDeclared(Lines("id name", "1 a"), header: null, previousLayout: null, a); + var third = PagerPayloadParser.ParseDeclared(Lines("id name", "3 c"), first.Header, b, a); + + third.ContentLines.Should().Equal("id name", "3 c"); + } + + [TestMethod] + [Description("Under a declared header, a data line that reads like the header is data: a column named 1 holding 1 renders its header and its row alike.")] + public void When_ADeclaredPageHasARowLikeItsHeader_Then_TheRowIsKept() + { + var layout = Declared(2, "1"); + var first = PagerPayloadParser.ParseDeclared(Lines("1", "-", "1"), header: null, previousLayout: null, layout); + var second = PagerPayloadParser.ParseDeclared(Lines("1", "-", "1"), first.Header, layout, layout); + + first.ContentLines.Should().Equal("1"); + second.ContentLines.Should().Equal("1"); + } + + [TestMethod] + [Description("A declared footer is exactly the lines it names: a data line that reads like a footer stays.")] + public void When_APayloadDeclaresItsFooter_Then_OnlyThoseLinesAreStripped() + { + var parsed = PagerPayloadParser.ParseDeclared( + Lines("Showing 1 of 2.", "Showing 2 of 9. Next data page: rerun with --result:cursor 2."), + header: null, + previousLayout: null, + RenderedLayout.None.WithFooter(1)); + + parsed.ContentLines.Should().Equal("Showing 1 of 2."); + } + + [TestMethod] + [Description("A page with no declared header, a list between two tables for instance, adds its lines only.")] + public void When_APageDeclaresNoHeader_Then_AllItsLinesAreContent() + { + var layout = Declared(2, "id", "name"); + var first = PagerPayloadParser.ParseDeclared(Lines("id name", "-- ----", "1 a"), header: null, previousLayout: null, layout); + var second = PagerPayloadParser.ParseDeclared(Lines("2 b"), first.Header, layout, RenderedLayout.None); + + first.Header.Lines.Should().Equal("id name", "-- ----"); + second.ContentLines.Should().Equal("2 b"); + } + + [TestMethod] + [Description("A header declared taller than the payload takes the lines there are, rather than reading past them.")] + public void When_ADeclaredHeaderIsTallerThanThePayload_Then_ItTakesTheLinesThereAre() + { + var parsed = PagerPayloadParser.ParseDeclared(Lines("id"), header: null, previousLayout: null, Declared(2, "id")); + + parsed.Header.Lines.Should().Equal("id"); + parsed.ContentLines.Should().BeEmpty(); + } + + [TestMethod] + [Description("A layout holds only what a rendering can have: no negative line counts, and a header exactly when there are columns.")] + public void When_ALayoutIsInconsistent_Then_ItCannotBeCreated() + { + FluentActions.Invoking(() => new RenderedLayout(-1, [])).Should().Throw(); + FluentActions.Invoking(() => new RenderedLayout(0, [], footerLineCount: -1)).Should().Throw(); + FluentActions.Invoking(() => new RenderedLayout(1, [])).Should().Throw(); + FluentActions.Invoking(() => new RenderedLayout(0, ["id"])).Should().Throw(); + } + + [TestMethod] + [Description("A custom pager renderer gets fetched pages as text only: a declared header naming the previous page's columns is stripped for it, as the built-in pager drops it, and one naming other columns is kept.")] + public async Task When_ACustomRendererFetchesDeclaredPages_Then_RepeatedHeadersAreStripped() + { + var renderer = new FetchingPagerRenderer(ReplPagerMode.More); + var pages = new Queue( + [ + new(Lines("id name", "-- ----", "22 bb"), HasMore: true, ContainsPresentationChrome: false, Declared(2, "id", "name")), + new(Lines("name id", "---- --", "c 3"), HasMore: false, ContainsPresentationChrome: false, Declared(2, "name", "id")), + ]); + + await ResultFlowPager.WriteAsync( + Lines("id name", "-- ----", "1 a"), + new StringWriter(), + new FakeKeyReader([]), + new ResultFlowPagerOptions + { + VisibleRows = 5, + PagerMode = ReplPagerMode.More, + HasMorePayload = true, + PayloadLayout = Declared(2, "id", "name"), + FetchNextPayload = _ => ValueTask.FromResult(pages.TryDequeue(out var page) ? page : null), + PagerRenderers = [renderer], + }, + CancellationToken.None); + + renderer.Fetched.Should().Equal("22 bb", Lines("name id", "---- --", "c 3")); + } + + private static string Lines(params string[] lines) => string.Join(Environment.NewLine, lines); + + private static RenderedLayout Declared(int headerLineCount, params string[] columns) => new(headerLineCount, columns); + private static ValueTask WritePagerAsync( string payload, TextWriter output, @@ -1200,6 +1326,26 @@ private static ValueTask WritePagerAsync( private static ConsoleKeyInfo MakeKey(ConsoleKey key, char keyChar) => new(keyChar, key, shift: false, alt: false, control: false); + private sealed class FetchingPagerRenderer(ReplPagerMode mode) : IReplPagerRenderer + { + public List Fetched { get; } = []; + + public ReplPagerMode Mode { get; } = mode; + + public async ValueTask RenderAsync(ReplPagerRenderContext context, CancellationToken cancellationToken = default) + { + while (context.CanFetchNextPayload + && await context.FetchNextPayloadAsync(cancellationToken).ConfigureAwait(false) is { } next) + { + Fetched.Add(next.Payload); + if (!next.HasMore) + { + break; + } + } + } + } + private sealed class RecordingPagerRenderer(ReplPagerMode mode) : IReplPagerRenderer { public List Payloads { get; } = [];