Functional Weave
Code in Rust

collections.paginate

Slice a list into a 1-based page and report the totals an API response needs.

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

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

What it does

Page numbers are 1-based, because every pagination UI, every query string and every API contract in the wild is 1-based. Page 0 is therefore a caller bug and raises.

A page past the end is NOT an error. It returns an empty items list with the real totals, because a client asking for page 9 of a collection that shrank to 2 pages between requests is a race, not a mistake, and it should render an empty page rather than a 500.

For example

  • paginate(items ×7, 1, 3) → items ×3, page 1, per page 3, total 7, total pages 3, has next true, has prev false first page of seven items, three at a time
  • paginate(items ×7, 2, 3) → items ×3, page 2, per page 3, total 7, total pages 3, has next true, has prev true a middle page has a page either side of it
  • paginate(items ×7, 3, 3) → items ×1, page 3, per page 3, total 7, total pages 3, has next false, has prev true the last page is short and has no next

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 paginate<T>(items: &[T], page: i64, per_page: i64) -> Page<T>
itemsT[]the whole collection, already filtered and sorted
pageint1-based page number; 1 is the first page
per_pageintpage size, 1 or greater
returnsPage<T>

The type it declares, generated into your project

/// One page of a collection, plus everything a client needs to draw a pager.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Page<T> {
    pub items: Vec<T>,
    pub page: i64,
    pub per_page: i64,
    pub total: i64,
    pub total_pages: i64,
    pub has_next: bool,
    pub has_prev: bool,
}

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

fune!(collections.paginate@^1);  // then call paginate(…)
impl/rust.rs · 95 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

/// Take the 1-based page `page` of `items`, `per_page` items at a time.
///
/// Page numbers are 1-based because every pagination UI, query string and API
/// contract in the wild is 1-based; page 0 can only come from a caller that has
/// the contract wrong, so it panics.
///
/// A page past the end is deliberately not an error. Between two requests a
/// collection can shrink under a client that is already on page 9, and that
/// client should render an empty page, not a 500.
///
/// Generic over the item type, like the TypeScript and Python versions: a page
/// of `Invoice` is a `Page<Invoice>`, and a page of open JSON records is a
/// `Page<Value>`.
///
/// # Panics
/// Panics if `per_page` is less than 1 or `page` is less than 1.
pub fn paginate<T: Clone>(items: &[T], page: i64, per_page: i64) -> Page<T> {
    // A page size of zero cannot mean "no limit": that would quietly hand back
    // the entire collection, which is the outage pagination exists to prevent.
    if per_page < 1 {
        panic!("per_page must be at least 1, received {}", per_page);
    }
    if page < 1 {
        panic!("page must be 1 or greater, received {}", page);
    }

    let total = items.len() as i64;
    // Zero items is zero pages, not one empty page, so `page <= total_pages` is
    // the one correct test for "does this page exist".
    let total_pages = (total + per_page - 1) / per_page;

    // Clamp rather than index: a start past the end is the documented
    // empty-page case, not a slice panic.
    let start = ((page - 1) * per_page).min(total) as usize;
    let end = (start as i64 + per_page).min(total) as usize;

    Page {
        items: items[start..end].to_vec(),
        page,
        per_page,
        total,
        total_pages,
        has_next: page < total_pages,
        // On page 9 of a 2-page collection there really are earlier pages.
        has_prev: page > 1,
    }
}

/// The 1-based page number a zero-based item index falls on.
///
/// # Panics
/// Panics if `index` is negative or `per_page` is less than 1.
pub fn page_of_index(index: i64, per_page: i64) -> i64 {
    if index < 0 {
        panic!("index must be a whole number of 0 or greater, received {}", index);
    }
    if per_page < 1 {
        panic!("per_page must be at least 1, received {}", per_page);
    }
    index / per_page + 1
}

pub fn page_to_value(page: &Page<Value>) -> Value {
    Value::obj(vec![
        ("items", Value::Arr(page.items.clone())),
        ("page", Value::Int(page.page)),
        ("perPage", Value::Int(page.per_page)),
        ("total", Value::Int(page.total)),
        ("totalPages", Value::Int(page.total_pages)),
        ("hasNext", Value::Bool(page.has_next)),
        ("hasPrev", Value::Bool(page.has_prev)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    // Refuse what the typed signature cannot hold, with the wording TypeScript
    // and Python use, rather than let the conversion below quietly change it.
    if let Value::Float(f) = args[1] {
        if f.fract() != 0.0 {
            panic!("page must be a whole number, received {}", f);
        }
    }
    if let Value::Float(f) = args[2] {
        if f.fract() != 0.0 {
            panic!("perPage must be a whole number, received {}", f);
        }
    }
    page_to_value(&paginate(
        args[0].as_arr(),
        args[1].as_i64(),
        args[2].as_i64(),
    ))
}

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 collections.paginate
Download for Rust collections.paginate-1.0.0-rust.fune · 11,963 bytes sha256 57c5d0e94d6d896351805275c98bb3940226ba8b9c2d9a357f2091209852d2ce

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

The whole function, every language, is one file too: collections.paginate-1.0.0.fune, 17,341 bytes, sha256 8929c0b7700311f6c92da9535e16d1b59c1b2c009d632583ebe0718405baa03b. 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 collections.paginate

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

// fune: after collections.paginate

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 collections.paginate --steps.

// fune: step collections.paginate 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
first page of seven items, three at a time items ×7, 1, 3 → items ×3, page 1, per page 3, total 7, total pages 3, has next true, has prev false
a middle page has a page either side of it items ×7, 2, 3 → items ×3, page 2, per page 3, total 7, total pages 3, has next true, has prev true
the last page is short and has no next items ×7, 3, 3 → items ×1, page 3, per page 3, total 7, total pages 3, has next false, has prev true
a page past the end is empty, not an error, and still reports the real totals items ×7, 9, 3 → items , page 9, per page 3, total 7, total pages 3, has next false, has prev true
the page immediately after the last one is empty items ×7, 4, 3 → items , page 4, per page 3, total 7, total pages 3, has next false, has prev true
a page size that divides exactly leaves no short page items ×6, 2, 3 → items ×3, page 2, per page 3, total 6, total pages 2, has next false, has prev true
one item per page makes as many pages as there are items items ×7, 4, 1 → items ×1, page 4, per page 1, total 7, total pages 7, has next true, has prev true
a single item is a single full page with nothing either side items ×1, 1, 10 → items ×1, page 1, per page 10, total 1, total pages 1, has next false, has prev false
an empty collection is zero pages, not one empty page , 1, 10 → items , page 1, per page 10, total 0, total pages 0, has next false, has prev false
page two of an empty collection still says there is something behind it , 2, 10 → items , page 2, per page 10, total 0, total pages 0, has next false, has prev true
Show the other 8 tests
CaseArgumentsExpected
a page size bigger than the collection returns all of it items ×2, 1, 50 → items ×2, page 1, per page 50, total 2, total pages 1, has next false, has prev false
records are passed through untouched, however uneven their shapes items ×3, 1, 2 → items ×2, page 1, per page 2, total 3, total pages 2, has next true, has prev false
a page size of zero is an error, never the whole collection items ×1, 1, 0 → error: must be at least 1
a negative page size is an error items ×1, 1, -5 → error: must be at least 1
page zero is a caller bug because pages are 1-based items ×1, 0, 10 → error: page must be 1 or greater
a negative page is a caller bug items ×1, -2, 10 → error: page must be 1 or greater
a fractional page number is a caller bug items ×1, 1.5, 10 → error: page must be a whole number
a fractional page size is a caller bug items ×1, 1, 2.5 → error: must be a whole number

More from the author

perPage of 0 or less is an error. There is no honest page of zero items: an unbounded page would silently return the whole collection, which is exactly the outage pagination exists to prevent. Ask for the whole list explicitly instead.

totalPages is 0 for an empty collection, not 1. Zero items really is zero pages, and it makes `page <= totalPages` the single correct test for whether a page exists.

hasPrev is `page > 1`, not `total > 0`: on page 9 of a 2-page collection there genuinely are earlier pages to go back to.

Files

PathBytes
README.md979
impl/python.py2,762
impl/rust.rs3,422
impl/typescript.ts2,415
vectors.json4,580