0

Template formatter with plural blocks

by · 1 day ago· ⑂ 0 remixes

Question: Template formatter with plural blocks

Companies: Figma

Difficulty: Easy

Problem statement

Implement format_template(text: str, keys: list[str], values: list[str]) -> str. keys[i] maps to values[i]; all values are strings.

text can contain two kinds of expressions in braces:

  1. Placeholder {name}: replace it with the value of name. Whitespace around the name is ignored.
  2. Plural block {name, plural, one{...} other{...}}: the value of name must be an integer. If it is 1, use the one branch; otherwise use the other branch. If there is no one branch, use other. Inside the chosen branch, every # becomes the number. A branch may also contain placeholders, which are expanded too.

Rules:

  • Text outside expressions is copied unchanged. # outside a plural branch is a plain character.
  • An expression whose name is not in keys is left in the output unchanged.
  • Raise an error for an unmatched { or }, a block type other than plural, a plural block with no other branch, or a plural value that is not an integer.
  • 0 <= len(text) <= 10^4.

Example 1

text   = "{n, plural, one{# layer} other{# layers}} hidden"
keys   = ["n"]
values = ["4"]
→ "4 layers hidden"

With values = ["1"] the result is "1 layer hidden".

Example 2

text   = "{owner} shared {k, plural, one{# board with {team}} other{# boards with {team}}}"
keys   = ["owner", "k", "team"]
values = ["Rosa", "1", "Design"]
→ "Rosa shared 1 board with Design"

Reconstructed from the reported question; details may differ from what interviewers ask.

Approach

Clarify first. Pin these down before writing code:

  • Values are strings. A plural block needs its value to parse as an integer. If it does not, raise an error.
  • Plural rule. Use English rules: 1 picks one, every other number (including 0) picks other. If one is missing, fall back to other. other is required.
  • What # means. Inside a plural branch, # becomes the count. Outside a branch it is a plain character ("Tag #1" stays as is).
  • Nesting. A branch can hold ordinary placeholders: one{# frame in {page}}. Say whether plural blocks may nest inside branches too (the parser below allows it for free).
  • Unknown names. Leave the expression unchanged, so a missing translation key is visible. Raising is also fine; agree on one.
  • Malformed input. An unmatched brace or an unknown block type is an error, not silent output.

Why not regex. re.sub(r"\{(\w+)\}", ...) handles part 1 of this problem. It breaks on part 2, because a plural block contains nested braces and a regex cannot match balanced braces. Interviewers expect you to see that and switch to a small parser.

Scan once, match braces by depth. Walk the string left to right and copy plain characters to the output. At a {, find its closing } by counting depth (+1 on {, -1 on }). The text between them is one expression:

  1. No comma: it is a placeholder. Look up the trimmed name.
  2. Otherwise split on the first two commas into name, kind, rest. kind must be plural. Parse rest as a list of selector{text} pairs, again matching braces by depth.
  3. Pick the branch, then render it recursively with the count bound. That one recursive call handles #, nested placeholders and nested plural blocks.

Invariant. When _render sees a {, every brace before it is balanced and already consumed. So _match_brace always finds the closer of exactly this expression, and each character is copied or examined a bounded number of times per nesting level.

Code

from __future__ import annotations


class TemplateError(ValueError):
    pass


def format_template(text: str, keys: list[str], values: list[str]) -> str:
    """Expand {name} placeholders and {name, plural, one{...} other{...}} blocks."""
    if len(keys) != len(values):
        raise TemplateError("keys and values must have the same length")
    env = dict(zip(keys, values))
    return _render(text, env, count=None)


def _render(text: str, env: dict[str, str], count: str | None) -> str:
    out: list[str] = []
    i, n = 0, len(text)
    while i < n:
        ch = text[i]
        if ch == "#" and count is not None:   # '#' only means something inside a branch
            out.append(count)
            i += 1
        elif ch == "{":
            end = _match_brace(text, i)
            out.append(_expand(text[i + 1:end], env))
            i = end + 1
        elif ch == "}":
            raise TemplateError(f"unmatched '}}' at index {i}")
        else:
            out.append(ch)
            i += 1
    return "".join(out)


def _match_brace(text: str, start: int) -> int:
    """Index of the '}' that closes the '{' at `start`."""
    depth = 0
    for j in range(start, len(text)):
        if text[j] == "{":
            depth += 1
        elif text[j] == "}":
            depth -= 1
            if depth == 0:
                return j
    raise TemplateError(f"unmatched '{{' at index {start}")


def _expand(body: str, env: dict[str, str]) -> str:
    """`body` is the text between one outer pair of braces."""
    if "," not in body:                       # simple placeholder
        name = body.strip()
        return env[name] if name in env else "{" + body + "}"
    name, kind, rest = (part.strip() for part in body.split(",", 2))
    if kind != "plural":
        raise TemplateError(f"unknown block type {kind!r}")
    branches = _parse_branches(rest)
    if "other" not in branches:
        raise TemplateError("plural block needs an 'other' branch")
    if name not in env:
        return "{" + body + "}"
    raw = env[name]
    try:
        n = int(raw)
    except ValueError:
        raise TemplateError(f"value of {name!r} is not an integer: {raw!r}")
    selector = "one" if n == 1 else "other"
    return _render(branches.get(selector, branches["other"]), env, count=raw)


def _parse_branches(rest: str) -> dict[str, str]:
    """Parse `one{...} other{...}` into {"one": "...", "other": "..."}."""
    branches: dict[str, str] = {}
    i, n = 0, len(rest)
    while i < n:
        if rest[i].isspace():
            i += 1
            continue
        j = i
        while j < n and rest[j] != "{" and not rest[j].isspace():
            j += 1
        selector = rest[i:j]
        while j < n and rest[j].isspace():
            j += 1
        if not selector or j >= n or rest[j] != "{":
            raise TemplateError(f"bad plural branch near {rest[i:]!r}")
        end = _match_brace(rest, j)
        branches[selector] = rest[j + 1:end]
        i = end + 1
    return branches

Complexity

  • Time: O(n * d) for a template of length n with brace nesting depth d. Each _match_brace scan covers only the expression it closes, and a character is rescanned once per enclosing level. Real templates have d <= 3, so this is linear in practice. Output size adds O(n + total substituted length).
  • Space: O(n + k) for the output and the k key/value pairs, plus O(d) recursion depth.
  • A single-pass version that tokenizes once (a stack of open braces) gives strict O(n); see Follow-ups.

Test cases

  • "Hi {name}!", name=Ada → "Hi Ada!"
  • "{n, plural, one{# file} other{# files}} selected" with n=1 → "1 file selected"
  • Same template, n=0 → "0 files selected"; n=12 → "12 files selected"
  • "{u} has {c, plural, one{# frame in {p}} other{# frames in {p}}}", u=Lin, c=3, p=Home → "Lin has 3 frames in Home" (placeholder inside a branch)
  • "Tag #1: {x}", x=ok → "Tag #1: ok" (# outside a branch is literal)
  • "{ name }", name=Bo → "Bo" (whitespace trimmed)
  • "{n, plural, other{# items}}", n=1 → "1 items" (no one branch, falls back to other)
  • "{missing} stays", no keys → "{missing} stays"
  • "" → ""
  • Errors: "{oops", "oops}", "{n, select, a{x} other{y}}", "{n, plural, one{x}}" (no other), and a plural value of "many" all raise TemplateError.

Follow-ups

  • Exact matches like =0{No files}. Check "=" + str(n) in the branches before the plural category. This is one extra dictionary lookup in _expand.
  • Other languages. Plural categories differ (zero, two, few, many in Arabic or Polish). Replace "one" if n == 1 else "other" with a plural_category(locale, n) function backed by CLDR rules. The parser does not change.
  • select blocks. {gender, select, female{...} male{...} other{...}} picks a branch by the string value. Add a kind == "select" case that uses the value itself as the selector, with other as fallback and no # binding.
  • Escaping. Support '{' (ICU style) or \{ for a literal brace. Handle it in the scanner so the brace never reaches depth counting.
  • Render many times. Parse the template once into a tree (Text, Var, Plural(name, branches), Hash), cache it by template string, and render the tree per call. This also gives strict O(n) parsing and lets you report every error with its position up front.

Interviewer expectations

Mid-level (L4)

  • Solves part 1 quickly, then notices nested braces in part 2 and moves off regex.
  • Gets 0 → other, 1 → one, and # replacement right.
  • Writes a few tests, including a missing key.

Senior (L5)

  • Asks about missing keys, non-integer counts and malformed input before coding.
  • Uses one recursive render so branches with placeholders work without special cases.
  • Keeps # literal outside plural branches, and gives clear errors for unmatched braces.

Staff (L6+)

  • Separates parsing from rendering and explains caching the parsed tree.
  • Knows the real format (ICU MessageFormat): locale plural categories, =N matches, select, escaping.
  • Discusses where this runs in a product (UI strings, translators editing templates) and why failing loudly on bad templates beats rendering broken text.

Sign in to comment, vote or remix.