Functional Weave
Code in Rust

form.character-count

The GOV.UK character count message for a text box: characters or words left or over a limit, with a threshold.

1.0.0 (not the latest) · published 2026-10-03 by charlie · Anterra

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

What it does

The message under a text box with a limit, worded exactly as the GOV.UK Design System's character count: "You have 12 characters remaining", "You have 1 character remaining", "You have 0 characters remaining", "You have 3 characters too many", "You have 1 word too many". It returns the numbers as well, so a form can mark the box in error when `over`, and the server can refuse the same text with the same count:

characterCount("Hello", 10, "characters", null)
// { count: 5, remaining: 5, over: false, visible: true,
//   message: "You have 5 characters remaining",
//   description: "You can enter up to 10 characters" }

For example

  • character_count(Hello, 10, characters, —) → count 5, remaining 5, over false, visible true, message You have 5 characters remaining, description You can enter up to 10 characters under the limit: characters left, plural
  • character_count(, 200, characters, —) → count 0, remaining 200, over false, visible true, message You have 200 characters remaining, description You can enter up to 200 characters nothing typed yet: the whole limit is left
  • character_count(abcd, 5, characters, —) → count 4, remaining 1, over false, visible true, message You have 1 character remaining, description You can enter up to 5 characters one left is singular

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.

pub fn character_count(text: &str, limit: i64, unit: &str, threshold: Option<i64>) -> CharacterCount
textstringwhat has been typed so far, exactly as it is
limitintthe most characters or words allowed, 1 or more
unitCountUnitcount characters (Unicode code points) or words (runs of non-whitespace)
thresholdint?percentage of the limit below which the message is not shown, 0 to 100; null shows it always
returnsCharacterCountthe count, what is left, and GOV.UK's message

The types it declares, generated into your project

// CountUnit is a string in Rust, one of: "characters", "words".
// Parameters take it as &str and results hold it as String.

/// How much has been typed against a limit, and what to tell the person.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CharacterCount {
    /// characters or words typed
    pub count: i64,
    /// limit less count; negative when over the limit
    pub remaining: i64,
    /// more than the limit has been typed
    pub over: bool,
    /// the message should be shown: always when over, else when count reaches the threshold
    pub visible: bool,
    /// "You have 12 characters remaining", "You have 1 word too many"
    pub message: String,
    /// the limit said up front, "You can enter up to 200 characters", for a page without JavaScript
    pub description: String,
}

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

fune!(form.character-count@^1);  // then call character_count(…)
impl/rust.rs · 102 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one

/// The Unicode White_Space property, spelled out so all three languages agree
/// on what separates words.
fn is_whitespace(ch: char) -> bool {
    let cp = ch as u32;
    (0x09..=0x0d).contains(&cp)
        || cp == 0x20
        || cp == 0x85
        || cp == 0xa0
        || cp == 0x1680
        || (0x2000..=0x200a).contains(&cp)
        || cp == 0x2028
        || cp == 0x2029
        || cp == 0x202f
        || cp == 0x205f
        || cp == 0x3000
}

fn count_words(text: &str) -> i64 {
    let mut words = 0;
    let mut in_word = false;
    for ch in text.chars() {
        if is_whitespace(ch) {
            in_word = false;
        } else if !in_word {
            in_word = true;
            words += 1;
        }
    }
    words
}

/// GOV.UK's character count: how many characters (code points) or words have
/// been typed against a limit, and the sentence to show under the text box.
///
/// # Panics
/// Panics if `limit` is below 1, `unit` is not characters or words, or
/// `threshold` is outside 0 to 100.
pub fn character_count(text: &str, limit: i64, unit: &str, threshold: Option<i64>) -> CharacterCount {
    if limit < 1 {
        panic!("limit must be a whole number of at least 1, received {}", limit);
    }
    if unit != "characters" && unit != "words" {
        panic!("unit must be characters or words, received {}", unit);
    }
    if let Some(t) = threshold {
        if !(0..=100).contains(&t) {
            panic!("threshold must be a whole number from 0 to 100, received {}", t);
        }
    }
    let count = if unit == "words" { count_words(text) } else { text.chars().count() as i64 };
    let remaining = limit - count;
    let over = remaining < 0;
    // count >= limit * threshold / 100, kept in whole numbers so 50% of 3 is 1.5, not 1.
    let visible = over || threshold.map_or(true, |t| (count as i128) * 100 >= (limit as i128) * (t as i128));
    let one = if unit == "words" { "word" } else { "character" };
    let noun = |n: i64| if n == 1 { one } else { unit };
    let message = if over {
        format!("You have {} {} too many", -remaining, noun(-remaining))
    } else {
        format!("You have {} {} remaining", remaining, noun(remaining))
    };
    CharacterCount {
        count,
        remaining,
        over,
        visible,
        message,
        description: format!("You can enter up to {} {}", limit, noun(limit)),
    }
}

pub fn character_count_to_value(c: &CharacterCount) -> Value {
    Value::obj(vec![
        ("count", Value::Int(c.count)),
        ("remaining", Value::Int(c.remaining)),
        ("over", Value::Bool(c.over)),
        ("visible", Value::Bool(c.visible)),
        ("message", Value::Str(c.message.clone())),
        ("description", Value::Str(c.description.clone())),
    ])
}

/// A whole number from a vector, or the same refusal the other languages give.
fn whole(v: &Value, message: &str) -> i64 {
    match v {
        Value::Int(n) => *n,
        Value::Float(f) if f.fract() == 0.0 => *f as i64,
        _ => panic!("{}, received {:?}", message, v),
    }
}

pub fn fune_vector(args: &[Value]) -> Value {
    let limit = whole(&args[1], "limit must be a whole number of at least 1");
    let threshold = if args[3].is_null() {
        None
    } else {
        Some(whole(&args[3], "threshold must be a whole number from 0 to 100"))
    };
    character_count_to_value(&character_count(args[0].as_str(), limit, args[2].as_str(), threshold))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add form.character-count
Download for Rust form.character-count-1.0.0-rust.fune · 18,395 bytes sha256 4d19e87c945d4c3585b0128308111902598dbb7a4ae208758d93fdd60f087f37

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./form.character-count-1.0.0-rust.fune, or fetch it from a terminal with fune pull form.character-count@1.0.0:rust.

The whole function, every language, is one file too: form.character-count-1.0.0.fune, 23,804 bytes, sha256 93f144d80817ef9293d4f4c9160ea5b7f788d1434f878b9fefeab57694eee108. 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 form.character-count

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

// fune: after form.character-count

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 form.character-count --steps.

// fune: step form.character-count 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
under the limit: characters left, plural Hello, 10, characters, — → count 5, remaining 5, over false, visible true, message You have 5 characters remaining, description You can enter up to 10 characters
nothing typed yet: the whole limit is left , 200, characters, — → count 0, remaining 200, over false, visible true, message You have 200 characters remaining, description You can enter up to 200 characters
one left is singular abcd, 5, characters, — → count 4, remaining 1, over false, visible true, message You have 1 character remaining, description You can enter up to 5 characters
exactly at the limit: 0 characters remaining, not over abcde, 5, characters, — → count 5, remaining 0, over false, visible true, message You have 0 characters remaining, description You can enter up to 5 characters
one over is singular, and remaining goes negative abcdef, 5, characters, — → count 6, remaining -1, over true, visible true, message You have 1 character too many, description You can enter up to 5 characters
three over abcdefgh, 5, characters, — → count 8, remaining -3, over true, visible true, message You have 3 characters too many, description You can enter up to 5 characters
an emoji is one character, not two UTF-16 units 😀😀😀, 3, characters, — → count 3, remaining 0, over false, visible true, message You have 0 characters remaining, description You can enter up to 3 characters
a decomposed accent (e + combining acute) is two code points Café, 5, characters, — → count 5, remaining 0, over false, visible true, message You have 0 characters remaining, description You can enter up to 5 characters
spaces and line breaks are characters too a b c, 10, characters, — → count 5, remaining 5, over false, visible true, message You have 5 characters remaining, description You can enter up to 10 characters
a limit of 1 is said in the singular , 1, characters, — → count 0, remaining 1, over false, visible true, message You have 1 character remaining, description You can enter up to 1 character
Show the other 22 tests
CaseArgumentsExpected
words: runs of non-whitespace The quick brown fox, 10, words, — → count 4, remaining 6, over false, visible true, message You have 6 words remaining, description You can enter up to 10 words
words: leading, trailing and repeated whitespace, tabs and line breaks do not make words one two three , 3, words, — → count 3, remaining 0, over false, visible true, message You have 0 words remaining, description You can enter up to 3 words
one word left is singular one two, 3, words, — → count 2, remaining 1, over false, visible true, message You have 1 word remaining, description You can enter up to 3 words
one word too many a b c d, 3, words, — → count 4, remaining -1, over true, visible true, message You have 1 word too many, description You can enter up to 3 words
three words too many a b c d e f, 3, words, — → count 6, remaining -3, over true, visible true, message You have 3 words too many, description You can enter up to 3 words
only whitespace is no words , 5, words, — → count 0, remaining 5, over false, visible true, message You have 5 words remaining, description You can enter up to 5 words
a no-break space separates words; punctuation does not one two,three, 5, words, — → count 2, remaining 3, over false, visible true, message You have 3 words remaining, description You can enter up to 5 words
below the threshold the message is not shown, but still worked out abcdefghij, 20, characters, 75 → count 10, remaining 10, over false, visible false, message You have 10 characters remaining, description You can enter up to 20 characters
exactly at the threshold it is shown abcdefghijklmno, 20, characters, 75 → count 15, remaining 5, over false, visible true, message You have 5 characters remaining, description You can enter up to 20 characters
50% of 3 is 1.5: one character is below it (whole-number division would say 1 and show it) a, 3, characters, 50 → count 1, remaining 2, over false, visible false, message You have 2 characters remaining, description You can enter up to 3 characters
50% of 3 is 1.5: two characters reach it ab, 3, characters, 50 → count 2, remaining 1, over false, visible true, message You have 1 character remaining, description You can enter up to 3 characters
a threshold of 0 shows the message with nothing typed , 10, characters, 0 → count 0, remaining 10, over false, visible true, message You have 10 characters remaining, description You can enter up to 10 characters
a threshold of 100 hides it until the limit abcdefghi, 10, characters, 100 → count 9, remaining 1, over false, visible false, message You have 1 character remaining, description You can enter up to 10 characters
over the limit is always shown, whatever the threshold abcdefghijk, 10, characters, 100 → count 11, remaining -1, over true, visible true, message You have 1 character too many, description You can enter up to 10 characters
the threshold applies to words when counting words a b, 10, words, 50 → count 2, remaining 8, over false, visible false, message You have 8 words remaining, description You can enter up to 10 words
a limit of 0 is refused abc, 0, characters, — → error: limit must be a whole number of at least 1
a negative limit is refused abc, -5, words, — → error: limit must be a whole number of at least 1
a fractional limit is refused abc, 2.5, characters, — → error: limit must be a whole number of at least 1
a threshold over 100 is refused abc, 10, characters, 101 → error: threshold must be a whole number from 0 to 100
a negative threshold is refused abc, 10, characters, -1 → error: threshold must be a whole number from 0 to 100
a fractional threshold is refused abc, 10, characters, 50.5 → error: threshold must be a whole number from 0 to 100
an unknown unit is refused abc, 10, letters, — → error: unit must be characters or words

More from the author

`react.form.textarea` shows it live as the person types. It is written in all three languages because the server has to enforce the same limit: a count the browser and the API disagree on is a form that says "0 remaining" and then rejects the post.

## Decisions

- **Characters are Unicode code points.** An emoji is 1, not the 2 UTF-16 units JavaScript's `length` (and GOV.UK's own script) counts, so all three languages agree. A decomposed accent (`e` plus a combining acute) is 2. Text is counted as given: a browser posts a line break in a textarea as `\r\n`, which is 2 characters, so normalise line breaks before counting on the server if the limit must match the browser's. - **Words are runs of anything but whitespace**, like GOV.UK's `/\S+/g`, with whitespace spelled out as the Unicode White_Space set (tab to carriage return, space, U+0085, no-break space, U+1680, U+2000-U+200A, the line and paragraph separators, U+202F, U+205F, U+3000) so every language splits alike. Punctuation does not split: `two,three` is one word. - **The threshold** is a percentage of the limit below which the message is not shown (`visible` false), as GOV.UK's `threshold` option. The comparison is `count * 100 >= limit * threshold` in whole numbers, so 50% of 3 is 1.5 and one character stays below it. `null` shows the message always; so does being over the limit, whatever the threshold. The message is worked out even when not visible. - **remaining** goes negative when over; `message` says the positive number "too many". - **description** is GOV.UK's `textareaDescriptionText`, "You can enter up to 200 characters" (singular for a limit of 1), for a page before its script runs.

Errors, identical in every language: `limit must be a whole number of at least 1`, `threshold must be a whole number from 0 to 100`, `unit must be characters or words`.

Sources: GOV.UK Design System, "Character count" https://design-system.service.gov.uk/components/character-count/; the wording and the threshold rule from govuk-frontend's `character-count.mjs` (`charactersUnderLimit`, `charactersAtLimit`, `charactersOverLimit`, `wordsUnderLimit`, `wordsAtLimit`, `wordsOverLimit`, `isOverThreshold`) https://github.com/alphagov/govuk-frontend/blob/main/packages/govuk-frontend/src/govuk/components/character-count/character-count.mjs.

Files

PathBytes
README.md3,015
impl/python.py2,699
impl/rust.rs3,479
impl/typescript.ts2,482
vectors.json7,736