5
\$\begingroup\$

I was looking for how to use MessageFormat.format() in Java with named parameters, as a similar option exists in Python, and found this question. Some functions are presented there, although they use internal cycles and StringBuilder, and apparently they can fall into infinite loops (comments there suggest that, and it makes sense).

Option 1 (potential infinite loop):

public static String format(String str, Map<String, Object> values) {

    StringBuilder builder = new StringBuilder(str);

    for (Entry<String, Object> entry : values.entrySet()) {

        int start;
        String pattern = "%(" + entry.getKey() + ")";
        String value = entry.getValue().toString();

        // Replace every occurence of %(key) with value
        while ((start = builder.indexOf(pattern)) != -1) {
            builder.replace(start, start + pattern.length(), value);
        }
    }

    return builder.toString();
}

Option 2 (infinite loop removed):

public static String dictFormat(String format, Hashtable<String, Object> values) {
    StringBuilder convFormat = new StringBuilder(format);
    Enumeration<String> keys = values.keys();
    ArrayList valueList = new ArrayList();
    int currentPos = 1;
    while (keys.hasMoreElements()) {
        String key = keys.nextElement(),
        formatKey = "%(" + key + ")",
        formatPos = "%" + Integer.toString(currentPos) + "$";
        int index = -1;
        while ((index = convFormat.indexOf(formatKey, index)) != -1) {
            convFormat.replace(index, index + formatKey.length(), formatPos);
            index += formatPos.length();
        }
        valueList.add(values.get(key));
        ++currentPos;
    }
    return String.format(convFormat.toString(), valueList.toArray());
}

And the question is, why not something simpler? Why is there an internal cycle? Here my option (which probably has some issue I'm not aware of).

public static String format(String str, Map<String, String> values) {

    String finalString = str;

    // validation input string
    if (str == null || str.isEmpty()) {
        return finalString;

    // validate parameters
    } else if (values == null || values.isEmpty()) {
        return str;
    }

    for (Map.Entry<String, String> entry : values.entrySet()) {

        String pattern = "%(" + entry.getKey() + ")";
        String value = entry.getValue();

        // Replace every occurrence of %(key) with value
        finalString = finalString.replace(pattern, value);
    }

    return finalString;
}
\$\endgroup\$
2
\$\begingroup\$

Performing string substitutions using multiple passes is almost always the wrong approach, and leads to bugs. If one of the values happens to be a string that looks like a %(key), then all sorts of unpredictable things could happen, including various uncontrolled format string attacks!

Therefore, the string replacements must be done in a single pass of the format string. I recommend doing it using a regular expression.

Furthermore, your method provides no escape mechanism, in case you need to specify a literal %(blah) in the format string. In Java, it would be customary to use backslash as an escape character.

Suggested solution

This solution uses Matcher.replaceAll(Function<MatchResult,String> replacer), which was introduced in Java 9, to provide each substitution text via a callback.

import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class NamedFormatter {
    private static final Pattern RE = Pattern.compile(
        "\\\\(.)" +         // Treat any character after a backslash literally 
        "|" +
        "(%\\(([^)]+)\\))"  // Look for %(keys) to replace
    );

    private NamedFormatter() {}

    /**
     * Expands format strings containing <code>%(keys)</code>.
     *
     * <p>Examples:</p>
     *
     * <ul>
     * <li><code>NamedFormatter.format("Hello, %(name)!", Map.of("name", "200_success"))</code> → <code>"Hello, 200_success!"</code></li>
     * <li><code>NamedFormatter.format("Hello, \%(name)!", Map.of("name", "200_success"))</code> → <code>"Hello, %(name)!"</code></li>
     * <li><code>NamedFormatter.format("Hello, %(name)!", Map.of("foo", "bar"))</code> → <code>"Hello, %(name)!"</code></li>
     * </ul>
     *
     * @param fmt The format string.  Any character in the format string that
     *            follows a backslash is treated literally.  Any
     *            <code>%(key)</code> is replaced by its corresponding value
     *            in the <code>values</code> map.  If the key does not exist
     *            in the <code>values</code> map, then it is left unsubstituted.
     *
     * @param values Key-value pairs to be used in the substitutions.
     *
     * @return The formatted string.
     */
    public static String format(String fmt, Map<String, Object> values) {
        return RE.matcher(fmt).replaceAll(match ->
            match.group(1) != null ?
                match.group(1) :
                values.getOrDefault(match.group(3), match.group(2)).toString()
        );
    }
}
\$\endgroup\$

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.