Functional Weave
Code in Python

text.mask

Mask all but the last n characters of a card, account or phone number, optionally keeping separators.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 16 tests, run in TypeScript, Python and Rust.

What it does

Replaces every character except the last `visible` with `maskChar`: "4242424242424242" with 4 visible is "************4242".

## Separators

For example

  • mask(4242424242424242, 4, *, false) → ************4242 a card number shows its last four
  • mask(4242 4242 4242 4242, 4, *, true) → **** **** **** 4242 separators kept: the grouping survives and still four digits show
  • mask(4242 4242 4242 4242, 4, *, false) → ***************4242 separators not kept: spaces are masked and count as characters

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

def mask(value: str, visible: int, mask_char: str, keep_separators: bool) -> str
valuestringthe text to mask
visibleinthow many characters to leave showing at the end, 0 or more
mask_charstringexactly one character, usually "*"
keep_separatorsboolleave spaces and hyphens in place and do not count them
returnsstringthe same number of characters as value

Your code names it in one line, in the file that uses it

from fune.text.mask import mask  # text.mask@^1
impl/python.py · 42 lines · open · raw
from typing import Any, List


def _whole(value: Any) -> bool:
    # bool is an int in Python; True is not a count of one here.
    return isinstance(value, int) and not isinstance(value, bool)


def _is_separator(ch: str) -> bool:
    # Only these two are separators: they are how card and sort-code numbers
    # are printed.
    return ch == " " or ch == "-"


def mask(value: str, visible: int, mask_char: str, keep_separators: bool) -> str:
    """Mask all but the last ``visible`` characters of ``value``.

    Characters are Unicode code points (a Python str is already indexed by
    code point), so an emoji counts once and the length never changes.
    """
    if not isinstance(value, str):
        raise TypeError("mask needs a string, received %r" % (value,))
    if not _whole(visible):
        raise TypeError("visible must be a whole number, received %r" % (visible,))
    if visible < 0:
        raise ValueError("visible must be 0 or greater, received %d" % (visible,))
    if not isinstance(mask_char, str) or len(mask_char) != 1:
        raise ValueError('maskChar must be exactly one character, received "%s"' % (mask_char,))

    out: List[str] = [""] * len(value)
    shown = 0
    # Walk from the end so "the last n" is counted without a second pass.
    for i in range(len(value) - 1, -1, -1):
        ch = value[i]
        if keep_separators and _is_separator(ch):
            out[i] = ch
        elif shown < visible:
            out[i] = ch
            shown += 1
        else:
            out[i] = mask_char
    return "".join(out)

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add text.mask
Download for Python text.mask-1.0.0-python.fune · 7,249 bytes sha256 a38d842c6923f08343ac86d256a9d2ad199f6cb9589113ccbc78c0691db2e86d

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./text.mask-1.0.0-python.fune, or fetch it from a terminal with fune pull text.mask@1.0.0:python.

The whole function, every language, is one file too: text.mask-1.0.0.fune, 10,511 bytes, sha256 e1bbb7d0741d771c6ccf2ae30859e642453a9ee706208c1183714ba54a1668fa. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

# fune: before text.mask

after — your function gets the result and the arguments, and returns the final result.

# fune: after text.mask

replace — it requires no other capability, so there is no dependency to replace.

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show text.mask --steps.

# fune: step text.mask after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
a card number shows its last four 4242424242424242, 4, *, false → ************4242
separators kept: the grouping survives and still four digits show 4242 4242 4242 4242, 4, *, true → **** **** **** 4242
separators not kept: spaces are masked and count as characters 4242 4242 4242 4242, 4, *, false → ***************4242
separators kept, the visible count skips over a separator 1234 5678, 5, *, true → ***4 5678
a hyphenated sort code with a different mask character 12-34-56, 2, #, true → ##-##-56
zero visible masks everything 12345678, 0, *, false → ********
visible equal to the length leaves the value alone 1234, 4, *, false → 1234
visible past the length leaves the value alone, unpadded 12, 4, *, false → 12
the empty string stays empty , 4, *, true →
dots are not separators: they are masked a.b.c, 1, *, true → ****c
Show the other 6 tests
CaseArgumentsExpected
accented letters count as one character each ÄÖÜß, 1, •, false → •••ß
an emoji is one character, not two UTF-16 units a😀b, 1, *, false → **b
an emoji is masked as a single mask character 😀😀x, 2, *, false → *😀x
a negative visible count is an error 1234, -1, *, false → error: visible must be 0 or greater
an empty mask character is an error 1234, 2, , false → error: maskChar must be exactly one character
a two-character mask would change the length, so it is an error 1234, 2, **, false → error: maskChar must be exactly one character

More from the author

With `keepSeparators` on, ASCII spaces and hyphens stay where they are and are not counted, so a card printed in groups keeps its shape and still shows its last four digits: "4242 4242 4242 4242" becomes "**** **** **** 4242", and the sort code "12-34-56" with 2 visible becomes "**-**-56". Only those two characters are separators; dots, slashes and brackets are masked like anything else, because they are just as often part of the secret.

With it off, separators are ordinary characters: they are masked and they count towards `visible`, so the output never reveals how a value was grouped.

## Counting

Characters are Unicode code points in all three languages, so an accented letter or an emoji counts once, and the result always has the same number of code points as the input. A letter written as a base letter plus a combining accent is two code points and is masked as two.

## Edge cases

- `visible` of 0 masks everything; `visible` at or above the length returns the value unchanged. Nothing is padded: a short value stays short. - A negative `visible`, or a `maskChar` that is not exactly one code point, is an error.

## What masking is not

Masking is for display. The digits you show must be ones you are allowed to show - PCI DSS permits at most the first six and last four of a card number - and the full value must not be sent to the page at all if it is not needed there. This function does not know what the value is.

Files

PathBytes
README.md1,599
impl/python.py1,561
impl/rust.rs1,595
impl/typescript.ts1,521
vectors.json2,075