6
\$\begingroup\$

On machines where I don't have C# 6 I use this named string interpolation method. I tried to make it as pretty a possible as far as good coding practices are concerned but I just can't get rid of the repeating code for braces validation and index incrementation. Somehow I don't like it.

public static string FormatFrom(this string text, object args, bool ignoreCase = true)
{
    var substrings = Regex.Split(text, "({{?)([A-Za-z_][A-Za-z0-9_]+)(}}?)");
    var argsType = args.GetType();
    var result = new StringBuilder(text.Length);

    const int leftBraceOffset = 0;
    const int propertyNameOffset = 1;
    const int rightBraceOffset = 2;

    for (int i = 0; i < substrings.Length; i++)
    {
        var leftBraceIndex = i + leftBraceOffset;
        var propertyNameIndex = i + propertyNameOffset;
        var rightBraceIndex = i + rightBraceOffset;

        var isPropertyName = substrings[leftBraceIndex] == "{" && substrings[rightBraceIndex] == "}";
        if (isPropertyName)
        {
            var propertyName = substrings[propertyNameIndex];
            var property = argsType.GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public);
            result.Append(property.GetValue(args));
            i += 2;
            continue;
        }

        var isEscapedPropertyName = substrings[leftBraceIndex] == "{{" && substrings[rightBraceIndex] == "}}";
        if (isEscapedPropertyName)
        {
            result.Append("{").Append(substrings[propertyNameIndex]).Append("}");
            i += 2;
            continue;
        }

        result.Append(substrings[i]);
    }

    return result.ToString();
}

var text = "Lorem {ipsum} {dolor} {{sit}} met.";
var obj = new { ipsum = "abc", dolor = 2.1 };
var text2 = text.FormatFrom(obj);

Result:

Lorem abc 2.1 {sit} met.

\$\endgroup\$
3
  • \$\begingroup\$ You say C# 6 is not available on those machines. What version do they run instead? \$\endgroup\$
    – Mast
    Nov 5 '15 at 9:50
  • \$\begingroup\$ @Mast the last before C# - Visual Studio 2013 \$\endgroup\$
    – t3chb0t
    Nov 5 '15 at 10:18
  • 1
    \$\begingroup\$ Can you install the v6 compiler (it's on NuGet) and keep targeting the old framework? All you need is to add a file to your project with the IFormattable interface. Jon Skeet did a great demo of it on you tube. \$\endgroup\$
    – RubberDuck
    Nov 5 '15 at 11:49
3
\$\begingroup\$

That's a seriously impressive answer from Heslacher. I remember doing something like this a while ago (turns out to be nearly 5 years ago) and thought I solved it completely with regexes. I've slightly modified it to use an object as it originally used a dictionary:

public static class StringExtensions
{
    private static Regex _parameterReplacementRegex =
            new Regex("(?<!{){(?<name>[a-zA-Z0-9]+)}(?!})",
                    RegexOptions.ExplicitCapture |
                    RegexOptions.Compiled);

    public static string FormatFrom(this string text, object args)
    {
        if (text == null)
        {
            throw new ArgumentNullException("text");
        }
        if (args == null)
        {
            return ReplaceDoubleBraces(text);
        }

        var argsType = args.GetType();
        var result = _parameterReplacementRegex.Replace(text, match =>
        {
            var paramName = match.Groups["name"].Value;
            var propertyInfo = argsType.GetProperty(paramName, BindingFlags.Instance | BindingFlags.Public);
            if (propertyInfo != null)
            {
                return propertyInfo.GetValue(args).ToString();
            }
            return "{" + paramName + "}";
        });
        return ReplaceDoubleBraces(result);
    }

    private static string ReplaceDoubleBraces(string result)
    {
        return Regex.Replace(result, "(\\{|\\}){2}", "$1");
    }
}

You'll notice my Regex uses a negative lookbehind and a negative lookahead to only capture non 'esacped' curly braces. I then use a MatchEvaluator delegate (called on each match) to replace it with the value from the args object or put the string back in its original format if there is no entry in the args object (return "{" + paramName + "}";). That could be modified to throw if you'd rather.

I'd also suggest the following additional test case:

[TestMethod()]
public void FormatFromTestArgsNullEscapesCurlyBrace()
{
    string expected = "{land}";
    string actual;
    actual = "{{land}}".FormatFrom(null);
    Assert.AreEqual(expected, actual);
}

To ensure it has consistent behaviour with string.Format.

I'm sure that this answer is less performant but I find it easier to reason about (but I am a Regex nut).

As a final point, I'd rename the method With or FormatWith :)

\$\endgroup\$
2
  • \$\begingroup\$ My first version of this method was based on regex replace but I didn't notice that I can actually handle the replace/match event and put a new value already there. I used a rookie replace. This is pretty cool ;-) \$\endgroup\$
    – t3chb0t
    Nov 5 '15 at 12:40
  • \$\begingroup\$ FormatWith vs FormatFrom... The first version was indeed FormatWith ;-) but then I decided to turn it around and instead of trying to put all properties into a string I look which properties I want to have and pull them from the object as needed. This way I can use a big object as a source and get only a few properties. I think this might be cheaper. \$\endgroup\$
    – t3chb0t
    Nov 5 '15 at 12:46
7
\$\begingroup\$
  • Always check the argument which is reffered by this in an extension method against null to early throw and return. Sure one could say it doesn't matter, because it will throw an ArgumentNullException but that would be thrown from the Regex.Split() method.

  • You don't check for args == null either.

  • The optional argument ignoreCase isn't used anywhere in that method so it can safely removed.

  • if by accident the property of the anonymous object isn't spelled exactly like in the string, the call to argsType.GetProperty() will return null and an NullReferenceException is thrown. Maybe it would be better for such a case to just assume it isn't a property. I will come back to this later.

  • if the passed in text only contains { the code will throw an IndexOutOfRange exception. This can be prevented by returning early if the length of text is < 3.

  • if the Length of substrings will be < 3 we can return early by returning text.

  • the regex pattern does not allow single letter variables being passed. So a text like {i} won't be matched.

Implementing the mentioned points will lead to

public static string FormatFrom(this string text, object args)
{
    if (text == null) { throw new ArgumentNullException("text"); }

    if (text.Length < 3 || string.IsNullOrWhiteSpace(text) || args==null) { return text; }

    var substrings = Regex.Split(text, "({{?)([A-Za-z_][A-Za-z0-9_]+)(}}?)")
                          .Where(s => s != string.Empty).ToArray();

    if (substrings.Length < 3) { return text; }

    var argsType = args.GetType();
    var result = new StringBuilder(text.Length);

    const int propertyNameOffset = 1;
    const int rightBraceOffset = 2;

    var bindingFlags = BindingFlags.Instance | BindingFlags.Public;

    for (int i = 0; i < substrings.Length; i++)
    {

        var possibleLeftBraces = substrings[i];
        var possibleRightBraces = substrings[i + rightBraceOffset];
        var propertyName = substrings[i + propertyNameOffset];

        var isPropertyName = possibleLeftBraces == "{" && possibleRightBraces == "}";
        if (isPropertyName)
        {
            var property = argsType.GetProperty(propertyName, bindingFlags);
            if (property == null)
            {
                result.Append("{").Append(propertyName).Append("}");
            }
            else
            {
                result.Append(property.GetValue(args, null));
            }
            i += 2;
            continue;
        }

        var isEscapedPropertyName = possibleLeftBraces == "{{" && possibleRightBraces == "}}";
        if (isEscapedPropertyName)
        {
            result.Append("{").Append(propertyName).Append("}");
            i += 2;
            continue;
        }

        result.Append(substrings[i]);
    }

    return result.ToString();
}  

which will pass all of these tests

[TestMethod()]
public void FormatFromTestStringEmptyShouldPass()
{
    string expected = string.Empty;
    string actual = string.Empty.FormatFrom(null);

    Assert.AreEqual(expected, actual);

}
[TestMethod(),ExpectedException(typeof(ArgumentNullException))]
public void FormatFromTestStrinNullShouldPass()
{

    string actual = ((string)null).FormatFrom(null);

    Assert.Inconclusive("Shouldn't happen !");

}
[TestMethod()]
public void FormatFromTestArgsNullShouldPass()
{
    string expected = "lala";
    string actual = "lala".FormatFrom(null);

    Assert.AreEqual(expected, actual);

}
[TestMethod()]
public void FormatFromTestParamsButArgsNullShouldPass()
{
    string expected = "{land}";
    string actual = "{land}".FormatFrom(null);

    Assert.AreEqual(expected, actual);

}
[TestMethod()]
public void FormatFromTestArgsNotNullShouldPass()
{
    string expected = "germany";
    string actual = "{land}".FormatFrom(new { land = "germany" });
    Assert.AreEqual(expected, actual);

}
[TestMethod()]
public void FormatFromTestArgsNotNullButWrongShouldPass()
{
    string expected = "{land}"; // TODO: Passenden Wert initialisieren
    string actual;
    actual = "{land}".FormatFrom(new { lan = "germany" });
    Assert.AreEqual(expected, actual);
}
\$\endgroup\$
2
  • \$\begingroup\$ I'm always forgetting about the null checks ;-) If there was always a way to speparate the workig code from the defensive one. \$\endgroup\$
    – t3chb0t
    Nov 5 '15 at 11:43
  • \$\begingroup\$ @t3chb0t aspect oriented programming / PostSharp on .NET... \$\endgroup\$ Nov 5 '15 at 12:29

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

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