Implement a printable table.

This commit implements a simple printable table that can be used to display
tabular data.

The columns of the table can specify a maximum width which will cause the
column text to wrap around to the next line.
This commit is contained in:
Peter Huene 2018-02-25 17:22:11 -08:00
parent 2ff85cdd9a
commit 9ef495327a
No known key found for this signature in database
GPG key ID: E1D265D820213D6A
16 changed files with 714 additions and 0 deletions

View file

@ -619,4 +619,7 @@ setx PATH "%PATH%;{0}"
<data name="ToolPackageConflictPackageId" xml:space="preserve">
<value>Tool '{0}' (version '{1}') is already installed.</value>
</data>
<data name="ColumnMaxWidthMustBeGreaterThanZero" xml:space="preserve">
<value>Column maximum width must be greater than zero.</value>
</data>
</root>

View file

@ -0,0 +1,226 @@
// Copyright (c) .NET Foundation and contributors. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using Microsoft.DotNet.Tools;
namespace Microsoft.DotNet.Cli
{
// Represents a table (with rows of type T) that can be printed to a terminal.
internal class PrintableTable<T>
{
private const string ColumnDelimiter = " ";
private List<Column> _columns = new List<Column>();
private class Column
{
public string Header { get; set; }
public Func<T, string> GetContent { get; set; }
public int MaxWidth { get; set; }
public override string ToString() { return Header; }
}
public void AddColumn(string header, Func<T, string> getContent, int maxWidth = int.MaxValue)
{
if (getContent == null)
{
throw new ArgumentNullException(nameof(getContent));
}
if (maxWidth <= 0)
{
throw new ArgumentException(
CommonLocalizableStrings.ColumnMaxWidthMustBeGreaterThanZero,
nameof(maxWidth));
}
_columns.Add(
new Column() {
Header = header,
GetContent = getContent,
MaxWidth = maxWidth
});
}
public void PrintRows(IEnumerable<T> rows, Action<string> writeLine)
{
if (rows == null)
{
throw new ArgumentNullException(nameof(rows));
}
if (writeLine == null)
{
throw new ArgumentNullException(nameof(writeLine));
}
var widths = CalculateColumnWidths(rows);
var totalWidth = CalculateTotalWidth(widths);
if (totalWidth == 0)
{
return;
}
foreach (var line in EnumerateHeaderLines(widths))
{
writeLine(line);
}
writeLine(new string('-', totalWidth));
foreach (var row in rows)
{
foreach (var line in EnumerateRowLines(row, widths))
{
writeLine(line);
}
}
}
public int CalculateWidth(IEnumerable<T> rows)
{
if (rows == null)
{
throw new ArgumentNullException(nameof(rows));
}
return CalculateTotalWidth(CalculateColumnWidths(rows));
}
private IEnumerable<string> EnumerateHeaderLines(int[] widths)
{
if (_columns.Count != widths.Length)
{
throw new InvalidOperationException();
}
return EnumerateLines(
widths,
_columns.Select(c => new StringInfo(c.Header ?? "")).ToArray());
}
private IEnumerable<string> EnumerateRowLines(T row, int[] widths)
{
if (_columns.Count != widths.Length)
{
throw new InvalidOperationException();
}
return EnumerateLines(
widths,
_columns.Select(c => new StringInfo(c.GetContent(row) ?? "")).ToArray());
}
private static IEnumerable<string> EnumerateLines(int[] widths, StringInfo[] contents)
{
if (widths.Length != contents.Length)
{
throw new InvalidOperationException();
}
if (contents.Length == 0)
{
yield break;
}
var builder = new StringBuilder();
for (int line = 0; true; ++line)
{
builder.Clear();
bool emptyLine = true;
bool appendDelimiter = false;
for (int i = 0; i < contents.Length; ++i)
{
// Skip zero-width columns entirely
if (widths[i] == 0)
{
continue;
}
if (appendDelimiter)
{
builder.Append(ColumnDelimiter);
}
var startIndex = line * widths[i];
var length = contents[i].LengthInTextElements;
if (startIndex < length)
{
var endIndex = (line + 1) * widths[i];
length = endIndex >= length ? length - startIndex : widths[i];
builder.Append(contents[i].SubstringByTextElements(startIndex, length));
builder.Append(' ', widths[i] - length);
emptyLine = false;
}
else
{
// No more content for this column; append whitespace to fill remaining space
builder.Append(' ', widths[i]);
}
appendDelimiter = true;
}
if (emptyLine)
{
// Yield an "empty" line on the first line only
if (line == 0)
{
yield return builder.ToString();
}
yield break;
}
yield return builder.ToString();
}
}
private int[] CalculateColumnWidths(IEnumerable<T> rows)
{
return _columns
.Select(c => {
var width = new StringInfo(c.Header ?? "").LengthInTextElements;
foreach (var row in rows)
{
width = Math.Max(
width,
new StringInfo(c.GetContent(row) ?? "").LengthInTextElements);
}
return Math.Min(width, c.MaxWidth);
})
.ToArray();
}
private static int CalculateTotalWidth(int[] widths)
{
int sum = 0;
int count = 0;
foreach (var width in widths)
{
if (width == 0)
{
// Skip zero-width columns
continue;
}
sum += width;
++count;
}
if (count == 0)
{
return 0;
}
return sum + (ColumnDelimiter.Length * (count - 1));
}
}
}

View file

@ -848,6 +848,11 @@ setx PATH "%PATH%;{0}"
<target state="new">Failed to uninstall tool package '{0}': {1}</target>
<note />
</trans-unit>
<trans-unit id="ColumnMaxWidthMustBeGreaterThanZero">
<source>Column maximum width must be greater than zero.</source>
<target state="new">Column maximum width must be greater than zero.</target>
<note />
</trans-unit>
</body>
</file>
</xliff>

View file

@ -848,6 +848,11 @@ setx PATH "%PATH%;{0}"
<target state="new">Failed to uninstall tool package '{0}': {1}</target>
<note />
</trans-unit>
<trans-unit id="ColumnMaxWidthMustBeGreaterThanZero">
<source>Column maximum width must be greater than zero.</source>
<target state="new">Column maximum width must be greater than zero.</target>
<note />
</trans-unit>
</body>
</file>
</xliff>

View file

@ -848,6 +848,11 @@ setx PATH "%PATH%;{0}"
<target state="new">Failed to uninstall tool package '{0}': {1}</target>
<note />
</trans-unit>
<trans-unit id="ColumnMaxWidthMustBeGreaterThanZero">
<source>Column maximum width must be greater than zero.</source>
<target state="new">Column maximum width must be greater than zero.</target>
<note />
</trans-unit>
</body>
</file>
</xliff>

View file

@ -848,6 +848,11 @@ setx PATH "%PATH%;{0}"
<target state="new">Failed to uninstall tool package '{0}': {1}</target>
<note />
</trans-unit>
<trans-unit id="ColumnMaxWidthMustBeGreaterThanZero">
<source>Column maximum width must be greater than zero.</source>
<target state="new">Column maximum width must be greater than zero.</target>
<note />
</trans-unit>
</body>
</file>
</xliff>

View file

@ -848,6 +848,11 @@ setx PATH "%PATH%;{0}"
<target state="new">Failed to uninstall tool package '{0}': {1}</target>
<note />
</trans-unit>
<trans-unit id="ColumnMaxWidthMustBeGreaterThanZero">
<source>Column maximum width must be greater than zero.</source>
<target state="new">Column maximum width must be greater than zero.</target>
<note />
</trans-unit>
</body>
</file>
</xliff>

View file

@ -848,6 +848,11 @@ setx PATH "%PATH%;{0}"
<target state="new">Failed to uninstall tool package '{0}': {1}</target>
<note />
</trans-unit>
<trans-unit id="ColumnMaxWidthMustBeGreaterThanZero">
<source>Column maximum width must be greater than zero.</source>
<target state="new">Column maximum width must be greater than zero.</target>
<note />
</trans-unit>
</body>
</file>
</xliff>

View file

@ -848,6 +848,11 @@ setx PATH "%PATH%;{0}"
<target state="new">Failed to uninstall tool package '{0}': {1}</target>
<note />
</trans-unit>
<trans-unit id="ColumnMaxWidthMustBeGreaterThanZero">
<source>Column maximum width must be greater than zero.</source>
<target state="new">Column maximum width must be greater than zero.</target>
<note />
</trans-unit>
</body>
</file>
</xliff>

View file

@ -848,6 +848,11 @@ setx PATH "%PATH%;{0}"
<target state="new">Failed to uninstall tool package '{0}': {1}</target>
<note />
</trans-unit>
<trans-unit id="ColumnMaxWidthMustBeGreaterThanZero">
<source>Column maximum width must be greater than zero.</source>
<target state="new">Column maximum width must be greater than zero.</target>
<note />
</trans-unit>
</body>
</file>
</xliff>

View file

@ -848,6 +848,11 @@ setx PATH "%PATH%;{0}"
<target state="new">Failed to uninstall tool package '{0}': {1}</target>
<note />
</trans-unit>
<trans-unit id="ColumnMaxWidthMustBeGreaterThanZero">
<source>Column maximum width must be greater than zero.</source>
<target state="new">Column maximum width must be greater than zero.</target>
<note />
</trans-unit>
</body>
</file>
</xliff>

View file

@ -848,6 +848,11 @@ setx PATH "%PATH%;{0}"
<target state="new">Failed to uninstall tool package '{0}': {1}</target>
<note />
</trans-unit>
<trans-unit id="ColumnMaxWidthMustBeGreaterThanZero">
<source>Column maximum width must be greater than zero.</source>
<target state="new">Column maximum width must be greater than zero.</target>
<note />
</trans-unit>
</body>
</file>
</xliff>

View file

@ -848,6 +848,11 @@ setx PATH "%PATH%;{0}"
<target state="new">Failed to uninstall tool package '{0}': {1}</target>
<note />
</trans-unit>
<trans-unit id="ColumnMaxWidthMustBeGreaterThanZero">
<source>Column maximum width must be greater than zero.</source>
<target state="new">Column maximum width must be greater than zero.</target>
<note />
</trans-unit>
</body>
</file>
</xliff>

View file

@ -848,6 +848,11 @@ setx PATH "%PATH%;{0}"
<target state="new">Failed to uninstall tool package '{0}': {1}</target>
<note />
</trans-unit>
<trans-unit id="ColumnMaxWidthMustBeGreaterThanZero">
<source>Column maximum width must be greater than zero.</source>
<target state="new">Column maximum width must be greater than zero.</target>
<note />
</trans-unit>
</body>
</file>
</xliff>

View file

@ -848,6 +848,11 @@ setx PATH "%PATH%;{0}"
<target state="new">Failed to uninstall tool package '{0}': {1}</target>
<note />
</trans-unit>
<trans-unit id="ColumnMaxWidthMustBeGreaterThanZero">
<source>Column maximum width must be greater than zero.</source>
<target state="new">Column maximum width must be greater than zero.</target>
<note />
</trans-unit>
</body>
</file>
</xliff>

View file

@ -0,0 +1,420 @@
// Copyright (c) .NET Foundation and contributors. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
using System;
using System.Collections.Generic;
using FluentAssertions;
using Microsoft.DotNet.Cli;
using Microsoft.DotNet.Tools.Test.Utilities;
using Xunit;
namespace Microsoft.DotNet.Tests
{
public class PrintableTableTests : TestBase
{
[Fact]
public void GivenNoColumnsItPrintsNoLines()
{
var table = new PrintableTable<string[]>();
var lines = new List<string>();
table.PrintRows(new string[][] {}, l => lines.Add(l));
lines.Should().BeEmpty();
}
[Fact]
public void GivenAnEmptyRowsCollectionItPrintsColumnHeaders()
{
RunTest(new TestData() {
Columns = new[] {
"First Column",
"2nd Column",
"Another Column"
},
Rows = new string[][] {
},
ExpectedLines = new[] {
"First Column 2nd Column Another Column",
"------------------------------------------------"
},
ExpectedTableWidth = 48
});
}
[Fact]
public void GivenASingleRowItPrintsCorrectly()
{
RunTest(new TestData() {
Columns = new[] {
"1st",
"2nd",
"3rd"
},
Rows = new string[][] {
new[] {
"first",
"second",
"third"
}
},
ExpectedLines = new[] {
"1st 2nd 3rd ",
"----------------------------",
"first second third"
},
ExpectedTableWidth = 28
});
}
[Fact]
public void GivenMultipleRowsItPrintsCorrectly()
{
RunTest(new TestData() {
Columns = new[] {
"First",
"Second",
"Third",
"Fourth",
"Fifth"
},
Rows = new string[][] {
new[] {
"1st",
"2nd",
"3rd",
"4th",
"5th"
},
new [] {
"a",
"b",
"c",
"d",
"e"
},
new [] {
"much longer string 1",
"much longer string 2",
"much longer string 3",
"much longer string 4",
"much longer string 5",
}
},
ExpectedLines = new[] {
"First Second Third Fourth Fifth ",
"----------------------------------------------------------------------------------------------------------------------------",
"1st 2nd 3rd 4th 5th ",
"a b c d e ",
"much longer string 1 much longer string 2 much longer string 3 much longer string 4 much longer string 5"
},
ExpectedTableWidth = 124
});
}
[Fact]
public void GivenARowWithEmptyStringsItPrintsCorrectly()
{
RunTest(new TestData() {
Columns = new[] {
"First",
"Second",
"Third",
"Fourth",
"Fifth"
},
Rows = new string[][] {
new[] {
"1st",
"2nd",
"3rd",
"4th",
"5th"
},
new [] {
"",
"",
"",
"",
""
},
new [] {
"much longer string 1",
"much longer string 2",
"much longer string 3",
"much longer string 4",
"much longer string 5",
}
},
ExpectedLines = new[] {
"First Second Third Fourth Fifth ",
"----------------------------------------------------------------------------------------------------------------------------",
"1st 2nd 3rd 4th 5th ",
" ",
"much longer string 1 much longer string 2 much longer string 3 much longer string 4 much longer string 5"
},
ExpectedTableWidth = 124
});
}
[Fact]
public void GivenColumnsWithMaximumWidthsItPrintsCorrectly()
{
RunTest(new TestData() {
Columns = new[] {
"First",
"Second",
"Third",
},
ColumnWidths = new[] {
3,
int.MaxValue,
4
},
Rows = new string[][] {
new[] {
"123",
"1234567890",
"1234"
},
new [] {
"1",
"1",
"1",
},
new [] {
"12345",
"a much longer string",
"1234567890"
},
new [] {
"123456",
"hello world",
"12345678"
}
},
ExpectedLines = new[] {
"Fir Second Thir",
"st d ",
"---------------------------------------",
"123 1234567890 1234",
"1 1 1 ",
"123 a much longer string 1234",
"45 5678",
" 90 ",
"123 hello world 1234",
"456 5678"
},
ExpectedTableWidth = 39
});
}
[Fact]
public void GivenARowContainingUnicodeCharactersItPrintsCorrectly()
{
RunTest(new TestData() {
Columns = new[] {
"Poem"
},
Rows = new string[][] {
new [] {
"\u3044\u308D\u306F\u306B\u307B\u3078\u3068\u3061\u308A\u306C\u308B\u3092"
}
},
ExpectedLines = new[] {
"Poem ",
"------------",
"\u3044\u308D\u306F\u306B\u307B\u3078\u3068\u3061\u308A\u306C\u308B\u3092"
},
ExpectedTableWidth = 12
});
}
[Fact]
public void GivenARowContainingUnicodeCharactersItWrapsCorrectly()
{
RunTest(new TestData() {
Columns = new[] {
"Poem"
},
ColumnWidths = new [] {
5
},
Rows = new string[][] {
new [] {
"\u3044\u308D\u306F\u306B\u307B\u3078\u3068\u3061\u308A\u306C\u308B\u3092"
}
},
ExpectedLines = new[] {
"Poem ",
"-----",
"\u3044\u308D\u306F\u306B\u307B",
"\u3078\u3068\u3061\u308A\u306C",
"\u308B\u3092 "
},
ExpectedTableWidth = 5
});
}
[Fact]
public void GivenARowContainingUnicodeCombiningCharactersItPrintsCorrectly()
{
// The unicode string is "test" with "enclosing circle backslash" around each character
// Given 0x20E0 is a combining character, the string should be four graphemes in length,
// despite having eight codepoints. Thus there should be 10 spaces following the characters.
RunTest(new TestData() {
Columns = new[] {
"Unicode String"
},
Rows = new string[][] {
new [] {
"\u0074\u20E0\u0065\u20E0\u0073\u20E0\u0074\u20E0"
}
},
ExpectedLines = new[] {
"Unicode String",
"--------------",
"\u0074\u20E0\u0065\u20E0\u0073\u20E0\u0074\u20E0 "
},
ExpectedTableWidth = 14
});
}
[Fact]
public void GivenARowContainingUnicodeCombiningCharactersItWrapsCorrectly()
{
// See comment for GivenARowContainingUnicodeCombiningCharactersItPrintsCorrectly regarding string content
// This should wrap after the second grapheme rather than the second code point (constituting the first grapheme)
RunTest(new TestData() {
Columns = new[] {
"01"
},
ColumnWidths = new[] {
2
},
Rows = new string[][] {
new [] {
"\u0074\u20E0\u0065\u20E0\u0073\u20E0\u0074\u20E0"
}
},
ExpectedLines = new[] {
"01",
"--",
"\u0074\u20E0\u0065\u20E0",
"\u0073\u20E0\u0074\u20E0"
},
ExpectedTableWidth = 2
});
}
[Fact]
public void GivenAnEmptyColumnHeaderItPrintsTheColumnHeaderAsEmpty()
{
RunTest(new TestData() {
Columns = new[] {
"First",
"",
"Third",
},
Rows = new string[][] {
new[] {
"1st",
"2nd",
"3rd"
}
},
ExpectedLines = new[] {
"First Third",
"-------------------------",
"1st 2nd 3rd "
},
ExpectedTableWidth = 25
});
}
[Fact]
public void GivenAllEmptyColumnHeadersItPrintsTheEntireHeaderAsEmpty()
{
RunTest(new TestData() {
Columns = new[] {
null,
"",
null,
},
Rows = new string[][] {
new[] {
"1st",
"2nd",
"3rd"
}
},
ExpectedLines = new[] {
" ",
"---------------------",
"1st 2nd 3rd"
},
ExpectedTableWidth = 21
});
}
[Fact]
public void GivenZeroWidthColumnsItSkipsTheColumns()
{
RunTest(new TestData() {
Columns = new[] {
"",
"First",
null,
"Second",
""
},
Rows = new string[][] {
new[] {
"",
"1st",
null,
"2nd",
""
}
},
ExpectedLines = new[] {
"First Second",
"-----------------",
"1st 2nd "
},
ExpectedTableWidth = 17
});
}
public class TestData
{
public IEnumerable<string> Columns { get; set; }
public int[] ColumnWidths { get; set; }
public IEnumerable<string[]> Rows { get; set; }
public IEnumerable<string> ExpectedLines { get; set; }
public int ExpectedTableWidth { get; set; }
}
private void RunTest(TestData data)
{
var table = new PrintableTable<string[]>();
int index = 0;
foreach (var column in data.Columns)
{
var i = index;
table.AddColumn(
column,
r => r[i],
data.ColumnWidths?[i] ?? int.MaxValue);
++index;
}
var lines = new List<string>();
table.PrintRows(data.Rows, l => lines.Add(l));
lines.Should().Equal(data.ExpectedLines);
table.CalculateWidth(data.Rows).Should().Be(data.ExpectedTableWidth);
}
}
}