6
\$\begingroup\$

Here's utility class that is capable of filling strings with placeholders and extract values of placeholders from filled strings.

Placeholders are enclosed in % characters or curly braces. Each placeholder has a pattern, which matches any value, that this placeholder can hold.

It's functionality assumes that string will be constructed once and used many times for filling and extracting placeholder values. In particular, this can be useful for message configuration and localization with more semantic placeholder names (instead of {0}, {1} and etc. in MessageFormat).

Usage example:

// mapOf creates HashMap<> from given key-value pairs
Map<String, String> patterns = mapOf("name", "\\w+", "id", "\\d+");

PlaceholdedString string = new PlaceholdedString("ID: {id}, Name: {name}", patterns::get);

// fill placeholders
Map<String, String> values = mapOf("name", "John", "id", "123");
// prints ID: 123, Name: John
System.out.println(string.fill(values::get));

// extract placeholder values
// prints {name=Lin, id=64}
System.out.println(string.extract("ID: 64, Name: Lin"));

PlaceholdedString class:

public class PlaceholdedString {

    private static final String PH_NAME = "[\\w\\-_]+";

    public static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{(" + PH_NAME + ")}|%(" + PH_NAME + ")%");

    @Getter
    private static class Placeholder {

        /**
         * Key (name) of this placeholder.
         */
        final String key;

        /**
         * Pattern which matches all possible values for this placeholder.
         */
        final Pattern pattern;

        /**
         * Group index in parent placeholded string's pattern.
         * Used to extract value of this placeholder from placeholded string's matcher.
         */
        final int group;

        /**
         * Index of this placeholder in parent placeholded string.
         */
        final int position;

        public Placeholder(String key, String regex, int group, int position) {
            this.key = key;
            this.pattern = Pattern.compile(regex);
            this.group = group;
            this.position = position;
        }

    }

    /**
     * Placeholded string, as given to constructor.
     */
    @Getter
    private final String value;

    /**
     * Regexp pattern which contains all placeholder's patterns.
     */
    @Getter
    private final Pattern pattern;

    /**
     * List of the placeholders in this string.
     */
    private final List<Placeholder> placeholders = new ArrayList<>();

    /**
     * Creates placeholded string.
     * If string contains placeholders which are not in 'placeholders' set, execption will be thrown.
     * @param pattern Pattern string with placeholders.
     * @param placeholders Set of available placeholder names.
     */
    public PlaceholdedString(@NonNull String pattern, @NonNull Set<String> placeholders) {
        this(pattern, k -> placeholders.contains(k) ? ".*" : null);
    }

    /**
     * Creates placeholded string.
     * If string contains placeholders for which no patterns present, execption will be thrown.
     * @param pattern Pattern string with placeholders.
     * @param patterns Mapping from placeholder names to placeholder regexp patterns.
     */
    public PlaceholdedString(@NonNull String pattern, @NonNull Function<String, String> patterns) {
        this.value = pattern;

        StringBuilder builder = new StringBuilder(pattern.length() + 32);

        Matcher m = PLACEHOLDER_PATTERN.matcher(pattern);

        int i = 0;

        int groupIndex = 1;

        while (i < pattern.length() && m.find(i)) {
            String key = m.group(1);
            if (key == null) {
                key = m.group(2);
            }

            String pt;
            switch (key) {
                case "percent":
                    pt = "%";
                    break;
                case "obracket":
                    pt = Pattern.quote("{");
                    break;
                case "cbracket":
                    pt = Pattern.quote("}");
                    break;
                default:
                    pt = patterns.apply(key);

                    if (pt == null) {
                        throw new IllegalArgumentException("Unexpected placeholder '" + key + "' in '" + pattern + "'");
                    }

                    break;
            }
            this.placeholders.add(new Placeholder(key, pt, groupIndex, m.start()));

            groupIndex++;
            for (char c : pt.toCharArray()) {
                if (c == '(') {
                    groupIndex++;
                }
            }

            int prefixLen = m.start() - i;
            if (prefixLen > 0) {
                builder.append(Pattern.quote(pattern.substring(i, i + prefixLen)));
            }

            builder.append('(').append(pt).append(')');

            i = m.end();
        }

        if (i < pattern.length()) {
            builder.append(Pattern.quote(pattern.substring(i)));
        }

        this.pattern = Pattern.compile(builder.toString());
    }

    /**
     * @return true, if this string has placeholders
     */
    public boolean hasPlaceholders() {
        return !this.placeholders.isEmpty();
    }

    /**
     * Replaces placeholders with actual values given by function.
     * Every value will be checked to match placeholder pattern (as specified in constructor).
     * @param values Function, that will return placeholder value by it's key.
     * @return Filled string.
     */
    public String fill(@NonNull Function<String, String> values) {
        if (this.placeholders.isEmpty()) {
            return this.value;
        }

        StringBuilder result = new StringBuilder(this.value.length() + 32);

        int i = 0;
        for (Placeholder placeholder : this.placeholders) {
            int prefixLen = placeholder.getPosition() - i;
            if (prefixLen > 0) {
                result.append(this.value.substring(i, i + prefixLen));
            }

            switch (placeholder.getKey()) {
                case "percent":
                    result.append("%");
                    break;
                case "obracket":
                    result.append("{");
                    break;
                case "cbracket":
                    result.append("}");
                    break;
                default:
                    String value = values.apply(placeholder.getKey());

                    if (value == null) {
                        throw new IllegalArgumentException("Placeholder '" + placeholder + "' has null value");
                    }

                    if (!placeholder.getPattern().matcher(value).matches()) {
                        throw new IllegalArgumentException("String '" + value + "' does not match " +
                                "pattern '" + placeholder.getPattern().pattern() + "' " +
                                "of placeholder '" + placeholder.getKey() + "'"
                        );
                    }

                    result.append(value);

                    break;
            }

            i = placeholder.getPosition() + placeholder.getKey().length() + 2;
        }

        if (i < this.value.length()) {
            result.append(this.value.substring(i));
        }

        return result.toString();
    }

    /**
     * Extract placeholder values from specified string.
     * String should match pattern used to create this object.
     * Returned map may be unmodifable.
     * @param s String, where placeholders were replaced with actual values.
     * @return Map, where keys are placeholder keys, and values are extracted values
     * or null, if given string does not match this string's pattern.
     */
    @Nullable
    public Map<String, String> extractNullable(@NonNull String s) {
        if (this.placeholders.isEmpty()) {
            return s.equals(this.value) ? Collections.emptyMap() : null;
        }

        // fast check
        if (!this.value.isEmpty()) {
            char c = this.value.charAt(0);

            // If first char in the pattern is not a start of placeholder:
            // Then check, whether the first char of the given string == first pattern's char
            // If it is not equals, then given string does not match pattern

            if (c != '%' && c != '{' && (s.isEmpty() || s.charAt(0) != c)) {
                return null;
            }
        } else {
            if (!s.isEmpty()) {
                return null;
            } else {
                return Collections.emptyMap();
            }
        }

        Matcher m = this.pattern.matcher(s);
        if (!m.matches()) {
            return null;
        }

        Map<String, String> result = new HashMap<>((int) Math.ceil(this.placeholders.size() / 0.75D));

        for (Placeholder placeholder : this.placeholders) {
            result.put(placeholder.getKey(), m.group(placeholder.getGroup()));
        }

        return result;
    }

    /**
     * Extract placeholder values from specified string.
     * String should match pattern used to create this object.
     * @param s String, where placeholder %keys% replaced with actual values.
     * @return Map, where keys are placeholder keys, and values are extracted values.
     */
    public Map<String, String> extract(@NonNull String s) {
        Map<String, String> map = extractNullable(s);

        if (map == null) {
            throw new IllegalArgumentException("String '" + s + "' does not match '" + this.pattern.pattern() + "'");
        }

        return map;
    }

    public List<Map<String, String>> extractAll(@NonNull String s) {
        if (s.isEmpty()) {
            return Collections.emptyList();
        }

        List<Map<String, String>> result = new ArrayList<>();

        Matcher m = this.pattern.matcher(s);
        while (m.find()) {
            Map<String, String> map = new HashMap<>();

            for (Placeholder placeholder : this.placeholders) {
                map.put(placeholder.getKey(), m.group(placeholder.getGroup()));
            }

            result.add(map);
        }

        return result;
    }

    /**
     * Throw an exception, if any of specified placeholder names is not contained in this string.
     * @param placeholdersRequired Names of required placeholders
     * @return this string
     */
    public PlaceholdedString require(@NonNull String... placeholdersRequired) {
        Set<String> absent = new HashSet<>(Arrays.asList(placeholdersRequired));

        // remove existed placeholders
        this.placeholders.forEach(p -> absent.remove(p.key));

        if (absent.size() > 0) {
            throw new IllegalArgumentException("String '" + this.value + "' requires placeholders: " + absent);
        }

        return this;
    }

}

I have optimized placeholded strings which have no placeholders: if so, fill() method just returns this string, and extract() method simply checks equality of the given string and pattern string, and returns emptyMap().

In my project this class is frequently used (both filling and extracting features), and good performance matters. How can I improve performance?

Also, looking for feedback about code style (methods & fields naming and order, code format, etc.).

\$\endgroup\$

1 Answer 1

1
\$\begingroup\$

Change the variable 'PH_NAME' to 'PLACEHOLDER_NAME'. You didn't shorten the name elsewhere and you should try to avoid abbreviating names anyway.

Whenever you have identical code in your program, you should consider making a method. Also, in this case you should be using an ENUM:

private enum RegexpPatternCharacters
{
    PERCENT("percent", "%"),
    OBRACKET("obracket", "{"),
    CBRACKET("cbracket", "}");

    private String key;
    private String label;

    RegexpPatternCharacters(String key, String label)
    {
        this.setKey(key);
        this.setLabel(label);
    }

    public String getKey()
    {
        return key;
    }

    public void setKey(String key)
    {
        this.key = key;
    }

    public String getLabel()
    {
        return label;
    }

    public void setLabel(String label)
    {
        this.label = label;
    }

    public static RegexpPatternCharacters fromKey(String key)
    {
        for (RegexpPatternCharacters enumValue : values())
        {
             if (enumValue.getKey().equals(key))
             {
                 return enumValue;
             }
        }
        return null; // Alternatively throw an error
    }
}

Now you can use REGEXP_PATTERN_CHARACTERS.fromKey(key), instead of a switch statement.

\$\endgroup\$
2
  • \$\begingroup\$ Thanks for the variable name advice, I agree. But... Why ENUM keyword and class name are all-caps (there is no 'ENUM' keyword in Java)? Why valueOf method is not static? Why enum fields are defined before enum constants (this is an error)? Why there is no semicolon after the last constant (this is also an error)? \$\endgroup\$
    – saharNooby
    Commented Apr 5, 2019 at 15:08
  • \$\begingroup\$ @saharNooby Was meant as an example, I updated my comment. You may want to make the enum public in it's own class \$\endgroup\$
    – dustytrash
    Commented Apr 5, 2019 at 15:29

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