Functional Weave
Code in Rust

text.truncate

Shorten text to a maximum length on a word boundary and add an ellipsis, counting Unicode code points.

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

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

What it does

Shortens text to at most `maxLength` characters, cutting at a word boundary and appending `ellipsis`: "Hello world" at 10 with "…" is "Hello…".

## The rules

For example

  • truncate(Hello world, 20, …) → Hello world text that fits is returned unchanged
  • truncate(Hello world, 11, …) → Hello world text exactly at the limit is returned unchanged, no ellipsis
  • truncate(Hello world, 10, …) → Hello… one over the limit backs up to the previous word

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 truncate(value: &str, max_length: i64, ellipsis: &str) -> String
valuestringthe text to shorten; returned unchanged if it already fits
max_lengthintthe longest result allowed, in code points, ellipsis included
ellipsisstringappended when text is cut, e.g. "…" or "..."; may be empty
returnsstring

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

fune!(text.truncate@^1);  // then call truncate(…)
impl/rust.rs · 70 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.
/// It happens to match `char::is_whitespace`, but JavaScript's \s and Python's
/// str.isspace draw the line differently, so the list is explicit everywhere.
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
}

/// Punctuation that reads badly directly before an ellipsis.
fn is_trailing_punctuation(ch: char) -> bool {
    matches!(ch, ',' | ';' | ':' | '-' | '.')
}

/// Shorten `value` to at most `max_length` code points, ellipsis included,
/// cutting at a word boundary where there is one.
///
/// # Panics
/// Panics if `max_length` is negative or shorter than the ellipsis.
pub fn truncate(value: &str, max_length: i64, ellipsis: &str) -> String {
    if max_length < 0 {
        panic!("maxLength must be 0 or greater, received {}", max_length);
    }
    let chars: Vec<char> = value.chars().collect();
    let mark_len = ellipsis.chars().count() as i64;
    if mark_len > max_length {
        panic!(
            "maxLength must be at least the length of the ellipsis, received {}",
            max_length
        );
    }
    if chars.len() as i64 <= max_length {
        return value.to_string();
    }

    let budget = (max_length - mark_len) as usize;
    let mut end = budget;
    // A cut just before whitespace is already a clean boundary.
    if !is_whitespace(chars[budget]) {
        // No boundary to move back to (none, or only at position 0): one long
        // word is cut hard rather than reduced to a bare ellipsis.
        if let Some(last_space) = (0..budget).rev().find(|&i| is_whitespace(chars[i])) {
            if last_space > 0 {
                end = last_space;
            }
        }
    }

    while end > 0 && (is_whitespace(chars[end - 1]) || is_trailing_punctuation(chars[end - 1])) {
        end -= 1;
    }
    let mut out: String = chars[..end].iter().collect();
    out.push_str(ellipsis);
    out
}

pub fn fune_vector(args: &[Value]) -> Value {
    Value::Str(truncate(args[0].as_str(), args[1].as_i64(), args[2].as_str()))
}

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 text.truncate
Download for Rust text.truncate-1.0.0-rust.fune · 8,891 bytes sha256 848761665445779eb00c3ce124f6df392b84f78ae8c1c91154975c26153edb59

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

The whole function, every language, is one file too: text.truncate-1.0.0.fune, 14,096 bytes, sha256 b7ad085ce8cf8efc872eace07a79adef908cc5061bb91d369cc64baf1819ae77. 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.truncate

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

// fune: after text.truncate

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.truncate --steps.

// fune: step text.truncate 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
text that fits is returned unchanged Hello world, 20, … → Hello world
text exactly at the limit is returned unchanged, no ellipsis Hello world, 11, … → Hello world
one over the limit backs up to the previous word Hello world, 10, … → Hello…
a cut that lands before a space keeps the whole word Hello world again, 12, … → Hello world…
the ellipsis counts towards the limit Hello world again, 14, ... → Hello world...
a trailing comma is dropped before the ellipsis Hello, world, 10, ... → Hello...
one long word is cut hard rather than lost Supercalifragilistic, 10, … → Supercali…
accented letters count once each Crème brûlée à la carte, 14, … → Crème brûlée…
emoji count as one code point, not two UTF-16 units 😀😀😀 abc, 5, … → 😀😀😀…
a line break is a word boundary Line one Line two, 10, … → Line one…
Show the other 8 tests
CaseArgumentsExpected
a no-break space is a word boundary too Mr Smithson, 9, … → Mr…
an empty ellipsis gives a plain cut Hello world, 5, → Hello
room for only the ellipsis Hello world, 1, … → …
the empty string fits any limit , 5, … →
zero length with no ellipsis is the empty string Hello, 0, →
a combining accent can be split from its letter: a documented limit café, 4, → cafe
a limit shorter than the ellipsis is an error Hello world, 2, ... → error: maxLength must be at least the length of the ellipsis
a negative limit is an error Hello world, -1, → error: maxLength must be 0 or greater

More from the author

1. If the text already fits in `maxLength`, it is returned unchanged, with no ellipsis. Nothing is trimmed or normalised. 2. Otherwise the text is cut to leave room for the ellipsis inside the limit: the result, ellipsis included, is never longer than `maxLength`. 3. If the cut lands exactly before whitespace, that is a clean word boundary and the cut stands. Otherwise it moves back to the last whitespace inside the kept part, so no word is split. 4. If there is no whitespace to move back to (one very long word, a URL, text in a script written without spaces), the word is cut hard at the limit. Splitting a word is better than returning nothing but an ellipsis. 5. Trailing whitespace and the trailing punctuation `, ; : - .` are removed from the cut before the ellipsis goes on, so "Hello, world" gives "Hello..." and not "Hello,...".

Whitespace is the Unicode White_Space set, spelled out character by character (tab, line feed, vertical tab, form feed, carriage return, space, U+0085, U+00A0, U+1680, U+2000-U+200A, U+2028, U+2029, U+202F, U+205F, U+3000) so all three languages agree; each language's built-in notion of "space" differs.

## Counting: code points, not graphemes

Length is counted in Unicode code points in all three languages. JavaScript's `.length` counts UTF-16 units, so a naive TypeScript version would count an emoji as two and disagree with Python; this one does not.

Code points are still not what a reader sees as one character. An accented letter written as a base letter plus a combining accent ("e" + U+0301) is two code points and a hard cut can separate them; a flag or a family emoji is several code points and can be cut in the middle. Getting that right needs the Unicode grapheme cluster rules (UAX #29), which none of the three standard libraries provides the same way, so this capability does not attempt it. If your text may contain such sequences, normalise it to NFC first (which fixes the accented letters) and leave some slack in `maxLength`.

## Errors

`maxLength` must be 0 or more, and at least the length of the ellipsis, since otherwise no truncated result can fit.

Files

PathBytes
README.md2,330
impl/python.py2,456
impl/rust.rs2,382
impl/typescript.ts2,517
vectors.json2,227