collections.search-text
Filter records by a case-insensitive substring match across named fields, in input order.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 20 tests, run in TypeScript, Python and Rust.
What it does
Case folding is ASCII only: A-Z fold to a-z and nothing else changes. JavaScript's toLowerCase, Python's str.lower and Rust's to_lowercase all implement full Unicode case mapping and disagree in the corners (Turkish dotted I, final sigma, German sharp s), so 'E' matches 'e' but 'E-acute' does not match 'e-acute'. Fold or transliterate the query yourself if you need more than that - text.slugify is one way.
An empty query returns every record, including records that carry none of the named fields. 'No filter typed yet' means 'show me everything', which is what every search box in the world does.
For example
search_text(records ×3, name, hopper)→ ×1 an ordinary substring match on one fieldsearch_text(records ×3, role, ENGINEER)→ ×2 matching is case-insensitive in both directionssearch_text(records ×2, name, )→ ×2 an empty query means show me everything, not show me nothing
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 search_text(records: &[Value], fields: &[String], query: &str) -> Vec<Value>
| records | record[] | open JSON-ish maps; shapes may differ between records |
| fields | string[] | the field names to search, at least one |
| query | string | the text to look for; empty matches everything |
| returns | record[] | the matching records, in input order |
Your code names it in one line, in the file that uses it
fune!(collections.search-text@^1); // then call search_text(…)
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
/// Largest integer JavaScript can hold exactly; beyond it the three languages disagree.
const SAFE_INTEGER: i64 = 9007199254740991;
/// Fold A-Z to a-z and leave every other character alone.
///
/// Deliberately not `to_lowercase()`: full Unicode case mapping differs between
/// JavaScript, Python and Rust (Turkish dotted I, final sigma, sharp s), and a
/// search that finds a row in one service but not in another is worse than one
/// that consistently ignores accents.
pub fn fold_ascii(text: &str) -> String {
text.chars()
.map(|ch| if ch.is_ascii_uppercase() { ch.to_ascii_lowercase() } else { ch })
.collect()
}
/// The searchable text of a value, or None if there is nothing to search.
///
/// Floats are skipped rather than rendered: no decimal form of them is
/// identical in all three languages, so matching on one could not be pinned.
fn searchable_text(value: &Value) -> Option<String> {
match value {
Value::Str(s) => Some(s.clone()),
Value::Bool(b) => Some((if *b { "true" } else { "false" }).to_string()),
Value::Int(i) if i.abs() <= SAFE_INTEGER => Some(i.to_string()),
_ => None,
}
}
/// The records whose text in any of `fields` contains `query`, in input order.
///
/// An empty query returns everything, because "nothing typed yet" means "show
/// me everything" in every search box ever built.
///
/// Records are `Value` because they are open JSON maps; a struct would be a
/// lie about data whose shape differs from one record to the next.
///
/// # Panics
/// Panics if `fields` is empty.
pub fn search_text(records: &[Value], fields: &[String], query: &str) -> Vec<Value> {
// An empty field list that quietly matches nothing is the classic bug that
// reaches users as "search is broken" with no other symptom.
if fields.is_empty() {
panic!("search_text needs at least one field to search");
}
let needle = fold_ascii(query);
if needle.is_empty() {
return records.to_vec();
}
let mut matches: Vec<Value> = Vec::new();
for record in records {
// Substring matching on UTF-8 bytes is the same answer as matching on
// code points, so Rust, Python and JavaScript agree on every string.
let hit = fields.iter().any(|field| match searchable_text(record.get(field)) {
Some(text) => fold_ascii(&text).contains(&needle),
None => false,
});
if hit {
matches.push(record.clone());
}
}
matches
}
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 !matches!(args[2], Value::Str(_)) {
panic!("query must be a string, received {:?}", args[2]);
}
let fields: Vec<String> = args[1]
.as_arr()
.iter()
.map(|v| v.as_str().to_string())
.collect();
Value::Arr(search_text(args[0].as_arr(), &fields, 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 collections.search-text
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./collections.search-text-1.0.0-rust.fune, or fetch it from a terminal with fune pull collections.search-text@1.0.0:rust.
The whole function, every language, is one file too: collections.search-text-1.0.0.fune, 17,452 bytes, sha256 7e612046153f7cc2aa437415ba1baa1a58113b96422d2b45da2ce8a0ca7c8f5f. 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.search-text
after — your function gets the result and the arguments, and returns the final result.
// fune: after collections.search-text
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.search-text --steps.
// fune: step collections.search-text 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.
| Case | Arguments | Expected | |
|---|---|---|---|
| an ordinary substring match on one field | records ×3, name, hopper | → | ×1 |
| matching is case-insensitive in both directions | records ×3, role, ENGINEER | → | ×2 |
| an empty query means show me everything, not show me nothing | records ×2, name, | → | ×2 |
| an empty query returns records that carry none of the searched fields | records ×2, name, | → | ×2 |
| nothing matching is an empty list, not an error | records ×2, name, zebra | → | |
| matches come back in input order, never in relevance order | records ×3, name, L | → | ×2 |
| any of the named fields can match | records ×2, name, role, admiral | → | ×1 |
| a record that is missing a searched field is skipped, not an error | records ×3, role, admiral | → | ×2 |
| a null field matches nothing | records ×2, role, a | → | ×1 |
| integers are searchable by their digits, so an id can be typed into the box | records ×3, id, 17 | → | ×2 |
Show the other 10 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| booleans are searchable as true and false in every language | records ×3, active, TRUE | → | ×2 |
| case folding is ASCII only, so an unaccented query does not find an accented word | records ×3, t, cafe | → | ×1 |
| an accented query matches the same accented letters, unfolded | records ×3, t, Café | → | ×1 |
| a fraction is not searchable: no decimal rendering of it is the same in three languages | records ×2, rate, 1.5 | → | ×1 |
| an integer too large to survive a JavaScript round trip is not searchable | records ×2, id, 9007 | → | ×1 |
| a list or a map in a searched field matches nothing | records ×3, tags, alpha | → | ×1 |
| one record, one match | records ×1, name, ada | → | ×1 |
| no records is no matches | , name, ada | → | |
| searching no fields at all is a caller bug, not a silent empty result | records ×1, , ada | → | error: at least one field |
| a non-string query is a caller bug | records ×1, id, 17 | → | error: query must be a string |
More from the author
An empty field list is an error. Silently matching nothing is the classic bug that reaches users as 'search is broken' with no other symptom.
Only strings, integers and booleans are searchable, so '17' finds the record whose id is 17 and 'true' finds the active ones. A float is skipped rather than matched, because no decimal rendering of it is identical in all three languages, and so are lists, maps, nulls and missing fields - a record is simply not matched on a field it has nothing searchable in.
Matching is a plain substring test, not a word, prefix or fuzzy match. Order is the input order, never relevance: this capability filters, it does not rank.
Files
| Path | Bytes |
|---|---|
| README.md | 1,293 |
| impl/python.py | 2,920 |
| impl/rust.rs | 3,084 |
| impl/typescript.ts | 2,795 |
| vectors.json | 4,730 |