Given a formattable string, is there a clean way to get the formatting string for the given arguments?

Viewed 331

Suppose I had a FormattableString like so:

var now = DateTime.Now;
FormattableString str = $"Today's date is: {now:yyyy-MM-dd} and some numbers: {new[]{1,2,3}}";

I'm trying to take a formattable string and transform it to be used in another component. In my case, essentially splitting the format string at the values so I can control how they're concatenated back, but I need the format string for the argument.

There are some limited methods on the FormattableString that allows me to get the argument values (GetArguments()/GetArgument()) and the original format string (Format), but there is none for accessing the argument formatting strings.

var format = str.Format; // "Today's date is: {0:yyyy-MM-dd} and some numbers: {1}"
var arguments = str.GetArguments(); // { now, new[]{1,2,3} }

// no simple way to get the "yyyy-MM-dd" part for arg 0

My workarounds I'm looking at are to preformat the value so the formatted value is set as the argument or parsing out the format string which would not be ideal.

void DumpLine(FormattableString format)
{
    var values = format.GetArguments().Prepend(null);
    var parts = Regex.Split(format.Format, @"\{[^}]+\}");
    Util.HorizontalRun(false, values.Zip(parts).SelectMany(x => new[] { x.First, x.Second }).Skip(1)).Dump();
}

// usage
DumpLine($"Today's date is: {now.ToString("yyyy-MM-dd")} and some numbers: {new[]{1,2,3}}");

Is this supported, perhaps through a helper class or am I out of luck?


Thank you mariusz96 for pointing out the new InterpolatedStringHandler functionality. It does for me exactly what I needed it for. It's even flexible enough for me to add additional parameters should I need it in the future. This is what I ended up with:

public static class UtilEx
{
    public static object Interpolate(IFormatProvider provider, [InterpolatedStringHandlerArgument(nameof(provider))] InterpolateFormatHandler handler) => Interpolate(handler);
    public static object Interpolate(InterpolateFormatHandler handler) => Util.HorizontalRun(false, handler.Items);

    [InterpolatedStringHandler]
    public ref struct InterpolateFormatHandler
    {
        private readonly IFormatProvider? provider;
        public InterpolateFormatHandler(int _literalLength, int _formattedCount, IFormatProvider? provider = default) => this.provider = provider;
        public List<object?> Items { get; } = new();
        
        public void AppendLiteral(string s) => Items.Add(s);
        public void AppendFormatted<T>(T t, int alignment, string format) => Items.Add(string.Format(provider, $"{{0,{alignment}:{format}}}", t));
        public void AppendFormatted<T>(T t, int alignment) => Items.Add(string.Format(provider, $"{{0,{alignment}}}", t));
        public void AppendFormatted<T>(T t, string format) => Items.Add(string.Format(provider, $"{{0:{format}}}", t));
        public void AppendFormatted<T>(T t) => Items.Add(t);
    }
}
2 Answers

none for accessing the argument formatting strings.. Is this supported, perhaps through a helper class or am I out of luck?

Interesting question. I'm tempted to say "no" - though I don't have any references/sources to cite I've always regarded FormattableString as a helper for interpolation that enables some parts of a program to know the difference between contexts where it receives a string, and contexts where it receives a formattable string - in essence, to know that something was once a string with format placeholders is helpful in cases like an SQL ORM running a raw command, and wanting to parameterize it. If it receives a FormattableString, it can parameterize the arguments and know where to insert them by parsing the format. If it straight received a formatted string it wouldn't be able to do that, so a FormattableString allows us to keep the format string and related arguments separated until the last moment.

When the compiler is turning an interpolated string into a formattable one, it has a relatively easy task. Take a look at what happens in this simple example, run through sharplab's compile/decompile cycle:

enter image description here

It's essentially just collecting variables mentioned inside a string, numbering them and swapping the interp out for a standard numerical placeholder format string. It doesn't need to touch the formatting specifiers when it converts {b:0000} -> {0:0000}

So the actual formats embedded in the placeholders aren't separated out at this stage; they're parsed out later. If we take a look at this internal method of a stringbuilder, which is what string.Format (eventually) defers to (which is what formattable string defers to), we can see it operating in statemachine parser style, hunting for non escaped {, parsing the numeric holder number, and then pulling the format and padding specifiers out by reference to commas and colons:

        //from .net framework reference source

        internal StringBuilder AppendFormatHelper(IFormatProvider provider, String format, ParamsArray args) {
            if (format == null) {
                throw new ArgumentNullException("format");
            }
            Contract.Ensures(Contract.Result<StringBuilder>() != null);
            Contract.EndContractBlock();
 
            int pos = 0;
            int len = format.Length;
            char ch = '\x0';
 
            ICustomFormatter cf = null;
            if (provider != null) {
                cf = (ICustomFormatter)provider.GetFormat(typeof(ICustomFormatter));
            }
 
            while (true) {
                int p = pos;
                int i = pos;
                while (pos < len) {
                    ch = format[pos];
 
                    pos++;
                    if (ch == '}')
                    {
                        if (pos < len && format[pos] == '}') // Treat as escape character for }}
                            pos++;
                        else
                            FormatError();
                    }
 
                    if (ch == '{')
                    {
                        if (pos < len && format[pos] == '{') // Treat as escape character for {{
                            pos++;
                        else
                        {
                            pos--;
                            break;
                        }
                    }
 
                    Append(ch);
                }
 
                if (pos == len) break;
                pos++;
                if (pos == len || (ch = format[pos]) < '0' || ch > '9') FormatError();
                int index = 0;
                do {
                    index = index * 10 + ch - '0';
                    pos++;
                    if (pos == len) FormatError();
                    ch = format[pos];
                } while (ch >= '0' && ch <= '9' && index < 1000000);
                if (index >= args.Length) throw new FormatException(Environment.GetResourceString("Format_IndexOutOfRange"));
                while (pos < len && (ch = format[pos]) == ' ') pos++;
                bool leftJustify = false;
                int width = 0;
                if (ch == ',') {
                    pos++;
                    while (pos < len && format[pos] == ' ') pos++;
 
                    if (pos == len) FormatError();
                    ch = format[pos];
                    if (ch == '-') {
                        leftJustify = true;
                        pos++;
                        if (pos == len) FormatError();
                        ch = format[pos];
                    }
                    if (ch < '0' || ch > '9') FormatError();
                    do {
                        width = width * 10 + ch - '0';
                        pos++;
                        if (pos == len) FormatError();
                        ch = format[pos];
                    } while (ch >= '0' && ch <= '9' && width < 1000000);
                }
 
                while (pos < len && (ch = format[pos]) == ' ') pos++;
                Object arg = args[index];
                StringBuilder fmt = null;
                if (ch == ':') {
                    pos++;
                    p = pos;
                    i = pos;
                    while (true) {
                        if (pos == len) FormatError();
                        ch = format[pos];
                        pos++;
                        if (ch == '{')
                        {
                            if (pos < len && format[pos] == '{')  // Treat as escape character for {{
                                pos++;
                            else
                                FormatError();
                        }
                        else if (ch == '}')
                        {
                            if (pos < len && format[pos] == '}')  // Treat as escape character for }}
                                pos++;
                            else
                            {
                                pos--;
                                break;
                            }
                        }
 
                        if (fmt == null) {
                            fmt = new StringBuilder();
                        }
                        fmt.Append(ch);
                    }
                }
                if (ch != '}') FormatError();
                pos++;
                String sFmt = null;
                String s = null;
                if (cf != null) {
                    if (fmt != null) {
                        sFmt = fmt.ToString();
                    }
                    s = cf.Format(sFmt, arg, provider);
                }
 
                if (s == null) {
                    IFormattable formattableArg = arg as IFormattable;
 
#if FEATURE_LEGACYNETCF
                    if(CompatibilitySwitches.IsAppEarlierThanWindowsPhone8) {
                        // TimeSpan does not implement IFormattable in Mango
                        if(arg is TimeSpan) {
                            formattableArg = null;
                        }
                    }
#endif
                    if (formattableArg != null) {
                        if (sFmt == null && fmt != null) {
                            sFmt = fmt.ToString();
                        }
 
                        s = formattableArg.ToString(sFmt, provider);
                    } else if (arg != null) {
                        s = arg.ToString();
                    }
                }
 
                if (s == null) s = String.Empty;
                int pad = width - s.Length;
                if (!leftJustify && pad > 0) Append(' ', pad);
                Append(s);
                if (leftJustify && pad > 0) Append(' ', pad);
            }
            return this;
        }

https://referencesource.microsoft.com/#mscorlib/system/text/stringbuilder.cs,2c3b4c2e7c43f5a4

All in, I'd say if you want those format arguments you'll need to pull them from the Format yourself; it's what the framework does. The code that does it is above, and could be tarted up/slimmed down but if you adopt the same approach with a helper as the framework does it should be similarly reliable and consistent in its behavior

ps; here's the source for the same method from .net core; essentially the same, but more nicely commented ;)

internal StringBuilder AppendFormatHelper(IFormatProvider? provider, string format, ParamsArray args)
        {
            if (format == null)
            {
                throw new ArgumentNullException(nameof(format));
            }

            int pos = 0;
            int len = format.Length;
            char ch = '\x0';

            ICustomFormatter? cf = null;
            if (provider != null)
            {
                cf = (ICustomFormatter?)provider.GetFormat(typeof(ICustomFormatter));
            }

            while (true)
            {
                while (pos < len)
                {
                    ch = format[pos];

                    pos++;
                    // Is it a closing brace?
                    if (ch == '}')
                    {
                        // Check next character (if there is one) to see if it is escaped. eg }}
                        if (pos < len && format[pos] == '}')
                        {
                            pos++;
                        }
                        else
                        {
                            // Otherwise treat it as an error (Mismatched closing brace)
                            FormatError();
                        }
                    }
                    // Is it an opening brace?
                    else if (ch == '{')
                    {
                        // Check next character (if there is one) to see if it is escaped. eg {{
                        if (pos < len && format[pos] == '{')
                        {
                            pos++;
                        }
                        else
                        {
                            // Otherwise treat it as the opening brace of an Argument Hole.
                            pos--;
                            break;
                        }
                    }
                    // If it's neither then treat the character as just text.
                    Append(ch);
                }

                //
                // Start of parsing of Argument Hole.
                // Argument Hole ::= { Index (, WS* Alignment WS*)? (: Formatting)? }
                //
                if (pos == len)
                {
                    break;
                }

                //
                //  Start of parsing required Index parameter.
                //  Index ::= ('0'-'9')+ WS*
                //
                pos++;
                // If reached end of text then error (Unexpected end of text)
                // or character is not a digit then error (Unexpected Character)
                if (pos == len || (ch = format[pos]) < '0' || ch > '9') FormatError();
                int index = 0;
                do
                {
                    index = index * 10 + ch - '0';
                    pos++;
                    // If reached end of text then error (Unexpected end of text)
                    if (pos == len)
                    {
                        FormatError();
                    }
                    ch = format[pos];
                    // so long as character is digit and value of the index is less than 1000000 ( index limit )
                }
                while (ch >= '0' && ch <= '9' && index < IndexLimit);

                // If value of index is not within the range of the arguments passed in then error (Index out of range)
                if (index >= args.Length)
                {
                    throw new FormatException(SR.Format_IndexOutOfRange);
                }

                // Consume optional whitespace.
                while (pos < len && (ch = format[pos]) == ' ') pos++;
                // End of parsing index parameter.

                //
                //  Start of parsing of optional Alignment
                //  Alignment ::= comma WS* minus? ('0'-'9')+ WS*
                //
                bool leftJustify = false;
                int width = 0;
                // Is the character a comma, which indicates the start of alignment parameter.
                if (ch == ',')
                {
                    pos++;

                    // Consume Optional whitespace
                    while (pos < len && format[pos] == ' ') pos++;

                    // If reached the end of the text then error (Unexpected end of text)
                    if (pos == len)
                    {
                        FormatError();
                    }

                    // Is there a minus sign?
                    ch = format[pos];
                    if (ch == '-')
                    {
                        // Yes, then alignment is left justified.
                        leftJustify = true;
                        pos++;
                        // If reached end of text then error (Unexpected end of text)
                        if (pos == len)
                        {
                            FormatError();
                        }
                        ch = format[pos];
                    }

                    // If current character is not a digit then error (Unexpected character)
                    if (ch < '0' || ch > '9')
                    {
                        FormatError();
                    }
                    // Parse alignment digits.
                    do
                    {
                        width = width * 10 + ch - '0';
                        pos++;
                        // If reached end of text then error. (Unexpected end of text)
                        if (pos == len)
                        {
                            FormatError();
                        }
                        ch = format[pos];
                        // So long a current character is a digit and the value of width is less than 100000 ( width limit )
                    }
                    while (ch >= '0' && ch <= '9' && width < WidthLimit);
                    // end of parsing Argument Alignment
                }

                // Consume optional whitespace
                while (pos < len && (ch = format[pos]) == ' ') pos++;

                //
                // Start of parsing of optional formatting parameter.
                //
                object? arg = args[index];

                ReadOnlySpan<char> itemFormatSpan = default; // used if itemFormat is null
                // Is current character a colon? which indicates start of formatting parameter.
                if (ch == ':')
                {
                    pos++;
                    int startPos = pos;

                    while (true)
                    {
                        // If reached end of text then error. (Unexpected end of text)
                        if (pos == len)
                        {
                            FormatError();
                        }
                        ch = format[pos];

                        if (ch == '}')
                        {
                            // Argument hole closed
                            break;
                        }
                        else if (ch == '{')
                        {
                            // Braces inside the argument hole are not supported
                            FormatError();
                        }

                        pos++;
                    }

                    if (pos > startPos)
                    {
                        itemFormatSpan = format.AsSpan(startPos, pos - startPos);
                    }
                }
                else if (ch != '}')
                {
                    // Unexpected character
                    FormatError();
                }

                // Construct the output for this arg hole.
                pos++;
                string? s = null;
                string? itemFormat = null;

                if (cf != null)
                {
                    if (itemFormatSpan.Length != 0)
                    {
                        itemFormat = new string(itemFormatSpan);
                    }
                    s = cf.Format(itemFormat, arg, provider);
                }

                if (s == null)
                {
                    // If arg is ISpanFormattable and the beginning doesn't need padding,
                    // try formatting it into the remaining current chunk.
                    if (arg is ISpanFormattable spanFormattableArg &&
                        (leftJustify || width == 0) &&
                        spanFormattableArg.TryFormat(RemainingCurrentChunk, out int charsWritten, itemFormatSpan, provider))
                    {
                        if ((uint)charsWritten > (uint)RemainingCurrentChunk.Length)
                        {
                            // Untrusted ISpanFormattable implementations might return an erroneous charsWritten value,
                            // and m_ChunkLength might end up being used in Unsafe code, so fail if we get back an
                            // out-of-range charsWritten value.
                            FormatError();
                        }

                        m_ChunkLength += charsWritten;

                        // Pad the end, if needed.
                        int padding = width - charsWritten;
                        if (leftJustify && padding > 0)
                        {
                            Append(' ', padding);
                        }

                        // Continue to parse other characters.
                        continue;
                    }

                    // Otherwise, fallback to trying IFormattable or calling ToString.
                    if (arg is IFormattable formattableArg)
                    {
                        if (itemFormatSpan.Length != 0)
                        {
                            itemFormat ??= new string(itemFormatSpan);
                        }
                        s = formattableArg.ToString(itemFormat, provider);
                    }
                    else if (arg != null)
                    {
                        s = arg.ToString();
                    }
                }
                // Append it to the final output of the Format String.
                if (s == null)
                {
                    s = string.Empty;
                }
                int pad = width - s.Length;
                if (!leftJustify && pad > 0)
                {
                    Append(' ', pad);
                }

                Append(s);
                if (leftJustify && pad > 0)
                {
                    Append(' ', pad);
                }
                // Continue to parse other characters.
            }
            return this;
        }

https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Text/StringBuilder.cs

Related