Problem
For some user-facing string, I want to truncate it to some given maximum length (also useful for file name/path lengths on Windows etc.). However, I want to do it a little more elaborately than just appending ...
or …
or so at the end, as I want to place that character in the middle.
I.e. I want both the start and the end of the string be visible, if truncated.
Of course, if the string is shorter, it does not need truncation and should be ignored.
Actually, I later saw in Java you have a great method StringUtils.abbrevate
in Apache Commons that goes much further, where abbreviateMiddle
may actually be, what I want to do here.
For my case, I choose to value the last side/end more, i.e. you may get 12…def
instead of 123…ef
if truncating 1234567890abcdef
to 6 characters. But that is an implementation detail, IMHO.
Solution
I started with ChatGPT making the basics/an idea and then some tests. I iterated on that and found out about the nice StringBuilder
, which I first passed to ChatGPT to adjust. Later I manually needed to adjust the code, as ChatGPT really has problems with counting and such applied maths (note I did use the GPT 3.5 version).
Also the tests (and somewhat TDD) really had helped me to get fix the issue, with odd maximum length numbers, as these resulted in strings being one character too short.
The last thing I changed, implementation-wise, was to adjust it to have a configurable separator
(aka "just" a Resharper refactor -> Introduce parameter and some test adjustments), because it was previously hardcoded to ...
. Thus, I also changed the default to …
, which is a much nicer Unicode character for an ellipsis.
I finished it by converting it to an extension method and documenting it, as that may be generally useful.
Obviously I tried following clean code and write as few commends as necessary. Also, the project I am working with, has a test pattern/coding guideline, where I should put tests for a method in a subclass named after that method, so tests get more tidy (similar to describe
blocks in jasmine
).
I also thought of using/switching to uint
for parameters that can never be < 0. However, even .NET's StringBuilder
just uses a "usual" ArgumentOutOfRangeException
and uses int
. As such, using uint
here would just require casting and the benefit is unclear, IMHO, if even .NET itself does not uses uint
here. (Though I am open for discussion, also about the fact why Microsoft implemented it that way in StringBuilder
.)
Implementation
using System;
using System.Diagnostics;
using System.Text;
/// <summary>
/// Common extensions for manipulating strings.
/// </summary>
public static class StringExtensions
{
/// <summary>
/// This truncates/abbreviates the string and places the separator as a user-facing indicator <i>in the middle</i> of that string.
/// </summary>
/// <remarks>For example "1234567890abcdef" gets truncated as "12…def" if you have a limit of 6 characters.</remarks>
/// <param name="input">the string to truncate</param>
/// <param name="lengthLimit">the maximum length of the resulting string</param>
/// <param name="separator">optionally, the separator to use, by default the ellipsis …</param>
/// <returns>The truncated string, if necessary.</returns>
/// <exception cref="ArgumentException">if the input parameters are invalid</exception>
public static string TruncateInMiddle(this string input, int lengthLimit, string separator = "…")
{
var middleIndex = lengthLimit / 2;
if (input.Length <= lengthLimit)
return input;
if (separator.Length > lengthLimit)
throw new ArgumentOutOfRangeException(nameof(separator),
$"Separator \"{separator}\" (length: {separator.Length}) must be _NOT_ be larger than the lengthLimit {lengthLimit}."
);
if (middleIndex < separator.Length)
throw new ArgumentOutOfRangeException(nameof(lengthLimit),
$"Length limit {lengthLimit} must be larger than double of length of the separation string \"{separator}\" (length: {separator.Length})x2 for proper display."
);
var result = new StringBuilder(lengthLimit, lengthLimit);
result.Append(input, 0, middleIndex - separator.Length);
result.Append(separator);
var remainingLength = result.Length;
Debug.Assert(remainingLength == middleIndex, "result.Length == middleIndex");
if (lengthLimit % 2 != 0)
remainingLength++;
result.Append(input, input.Length - remainingLength, remainingLength);
return result.ToString();
}
}
Tests
Using NUnit and FluentAssertions.
[TestOf(typeof(StringExtensions))]
public class StringExtensionsTest
{
public class TruncateInMiddle : StringExtensionsTest
{
[TestCase(0, 1)]
[TestCase(1, 2)]
[TestCase(2, 3)]
[TestCase(3, 4)]
[TestCase(4, 5)]
[TestCase(1, 5)]
public void ThrowsIfSeparatorIsTooLong(int lengthLimit, int separatorLength)
{
// Arrange
var separator = TestContext.CurrentContext.Random.GetString(separatorLength);
// Act
var action = () => "doesNotMatter".TruncateInMiddle(lengthLimit, separator);
// Assert
action.Should().ThrowExactly<ArgumentOutOfRangeException>()
.WithMessage("*(Parameter 'separator')")
.WithMessage("*Separator * must be _NOT_ be larger than * lengthLimit*")
.WithMessage($"*{lengthLimit}*")
.WithMessage($"*\"{separator}\"*")
.WithMessage($"*(length: {separatorLength}*");
}
[Test]
public void ThrowsForTooSmallLengthLimits([Range(4, 5)] int lengthLimit)
{
// Act
var action = () => "doesNotMatter".TruncateInMiddle(lengthLimit, "...");
// Assert
action.Should().ThrowExactly<ArgumentOutOfRangeException>()
.WithMessage("*(Parameter 'lengthLimit')")
.WithMessage("*Length limit * must be larger than double of length of the separation string*")
.WithMessage($"*{lengthLimit}*")
.WithMessage("*\"...\"*")
.WithMessage("*(length: 3)x2*");
}
[TestCase("abcde", 4, "..", ExpectedResult = "..de")]
[TestCase("123456789", 5, "..", ExpectedResult = "..789")]
[TestCase("1234567890abcdef", 6, "...", ExpectedResult = "...def")]
[TestCase("1234567890abcdef", 6, "…", ExpectedResult = "12…def")]
public string TruncatesMinimalExamplesCorrectly(string inputString, int lengthLimit, string separator)
{
// Act
var outputString = inputString.TruncateInMiddle(lengthLimit, separator);
Debug.WriteLine(outputString);
// Assert
outputString.Should().HaveLength(lengthLimit)
.And.Contain(separator);
return outputString;
}
[Test]
public void AppliesLengthLimit([Random(100, 150, 5)] int randomLength, [Random(2, 100, 5)] int lengthLimit)
{
// Arrange
var inputString = TestContext.CurrentContext.Random.GetString(randomLength);
Debug.WriteLine(inputString);
// Act
var outputString = inputString.TruncateInMiddle(lengthLimit);
Debug.WriteLine(outputString);
// Assert
outputString.Should().HaveLength(lengthLimit)
.And.Contain("…");
}
[Test]
public void ReturnsStringsSmallerThanLimitUnchanged([Random(2, 100, 5)] int randomLength,
[Random(100, 150, 5)] int lengthLimit)
{
// Arrange
var inputString = TestContext.CurrentContext.Random.GetString(randomLength);
Debug.WriteLine(inputString);
// Act
var outputString = inputString.TruncateInMiddle(lengthLimit);
Debug.WriteLine(outputString);
// Assert
outputString.Should().Be(inputString);
}
}
}
.NET 6.0
ArgumentOutOfRangeException
class. \$\endgroup\$