3
\$\begingroup\$

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

\$\endgroup\$
3
  • 1
    \$\begingroup\$ Is there any particular reason why do you target .NET 6? In case of .NET 8 your fail fast checks could be more streamlined due to the new static methods of the ArgumentOutOfRangeException class. \$\endgroup\$ Commented Feb 8 at 8:15
  • \$\begingroup\$ @PeterCsala No, but thanks yeah. It was just because the project uses this version currently. \$\endgroup\$
    – rklec
    Commented Feb 8 at 16:57
  • \$\begingroup\$ Updated with answers and here the new version is: gist.github.com/rklec/8a78ae5fb5e5bcea0dcd1920d1caa0a4 \$\endgroup\$
    – rklec
    Commented Feb 12 at 14:04

2 Answers 2

2
\$\begingroup\$

Let me present you an alternative version of your TruncateInMiddle which utilizes ReadOnlySpans. The core logic can be rewritten like this

ReadOnlySpan<char> inputSpan = input.AsSpan();
var prefixEnd = middleIndex - separator.Length;
ReadOnlySpan<char> prefix = inputSpan[..prefixEnd];

var remainingLength = lengthLimit % 2 == 0 ? middleIndex : middleIndex + 1;
var suffixStart = input.Length - remainingLength;
ReadOnlySpan<char> suffix = inputSpan[suffixStart..]; 

return string.Concat(prefix, separator, suffix);
  • prefix points to the beginning of the input for a given length
  • suffix points to the end of the input from a calculated index
  • the end result is a simple string concatenation

I've run a simple benchmark to measure my implementation

BenchmarkRunner.Run<TruncateExperiment>();
[HtmlExporter]
[MemoryDiagnoser]
[SimpleJob(BenchmarkDotNet.Engines.RunStrategy.ColdStart, launchCount: 20)]
public class TruncateExperiment
{
    List<string> inputs = new();

    [GlobalSetup]
    public void Setup()
    {
        for(int i = 1; i < 100; i++)
        {
            inputs.Add(string.Join("", 
                Enumerable.Repeat(0, Random.Shared.Next(100))
                    .Select(n => (char)Random.Shared.Next(127))));
        }
    }

    [Benchmark(Baseline = true)]
    public void RunBaseLine() 
    { 
        foreach(var input in inputs) 
            input.TruncateInMiddle(6); 
    }

    [Benchmark]
    public void RunNew()
    {
        foreach(var input in inputs) 
            input.TruncateInMiddleNew(6);
    }
}

The results on my machine

| Method      | Mean     | Error    | StdDev    | Median   | Ratio | RatioSD | Allocated | Alloc Ratio |
|------------ |---------:|---------:|----------:|---------:|------:|--------:|----------:|------------:|
| RunBaseLine | 8.502 us | 2.885 us | 39.154 us | 4.333 us |  1.00 |    0.00 |  11.97 KB |        1.00 |
| RunNew      | 7.384 us | 2.783 us | 37.761 us | 3.375 us |  0.80 |    0.18 |   4.31 KB |        0.36 |

It's a bit faster and uses less memory.

\$\endgroup\$
6
  • 1
    \$\begingroup\$ Clever idea! Though I'd say the difference is minimal and depending on where you want to use the function and how performance-critical it is, one may find the StringBuilder version more readable/easier to understand, possibly. \$\endgroup\$
    – rklec
    Commented Feb 8 at 17:01
  • 1
    \$\begingroup\$ @rklec StringBuilder would be valid in your case if you're on .NET 4. or older. But .NET 5+ /Core, you should use Span, is more performant, and memory efficient. \$\endgroup\$
    – iSR5
    Commented Feb 8 at 18:48
  • \$\begingroup\$ Thanks! Did that and combined with other answer and got this result: gist.github.com/rklec/8a78ae5fb5e5bcea0dcd1920d1caa0a4 Also changed it to use nameof for WithMessage assertions in tests to check for the parameter. \$\endgroup\$
    – rklec
    Commented Feb 12 at 14:03
  • 1
    \$\begingroup\$ @rklec It seems good to me. \$\endgroup\$ Commented Feb 12 at 14:10
  • 1
    \$\begingroup\$ @rklec If you are fine with the end result then please consider to mark one of the posts as the answer. \$\endgroup\$ Commented Feb 12 at 16:32
2
\$\begingroup\$

Thanks to Peter Csala for providing you an example on how to convert your code into using Span<T>, and I think his answer covered that. I just want to add some points on other parts of the code.

  • Code does not validates input and separator against whitespace, empty, and null cases ( a simple IsNullOrWhiteSpace would cover them all).

  • You should use a template message for detailed messages instead, something like :

    private const string SeparatorLengthLargerThanLengthLimitExceptionMessage = "Separator '{0}' (length: {1}) must be _NOT_ be larger than the lengthLimit {2}.";
    
    if (separator.Length > lengthLimit)
      throw new ArgumentOutOfRangeException(nameof(separator),             
        string.Format(SeparatorLengthLargerThanLengthLimitExceptionMessage, separator, separator.Length, lengthLimit));   
    
  • lengthLimit is not clear enough of its purpose, maybe using a more common used naming conversions would be better such as sliceSize, maxLength, limit ..etc. If you got stuck on naming, you can always explore .NET methods with similar functionality or explore other known libraries.

  • separator a char would be a better choice, and you can add a separate argument for count number of repeated chars like new string('.',count)

  • lengthLimit / 2 assumes that lengthLimit is always even! to better handle this you can use (int)Math.Ceiling(lengthLimit/2d); and then you can remove this part :

    if (lengthLimit % 2 != 0)
    remainingLength++;
    
\$\endgroup\$
3
  • \$\begingroup\$ Thanks! Did everything except separator as a char. char separator = new string('.', 3) does not work in the method head as it is not compiler-time-constant and also I want to use any string of any length as a separator, not only . and not only three times. Especially I want the UTF-8 character as the default. I could only replace it with ReadOnlySpan<char>, but then string.IsNullOrWhiteSpace is again problematic) and I have problems setting a default again. Okay the last issue could be fixed with an overload, because I cannot get ReadOnlySpan<char>? to work for a separator. \$\endgroup\$
    – rklec
    Commented Feb 12 at 13:59
  • \$\begingroup\$ separator = new string('.', 3) will return a string not char. perhaps my answer lead you to some confusion ?. nethertheless, is char and not string. when I mentioned new string('.', 3) I just wanted to show you how it was implemented in .NET. which would be safer and maintainable approach, and widely used as well. so if you do char separator = '…' in your method arguments, it would compile as intended. \$\endgroup\$
    – iSR5
    Commented Feb 12 at 18:54
  • \$\begingroup\$ input is null && input.Length <= maxLength could be replaced with IsNullOrWhiteSpace and return input as early as possible. because there is nothing to do at this point. \$\endgroup\$
    – iSR5
    Commented Feb 12 at 18:56

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.