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
searchText(records ×3, name, hopper)→ ×1 an ordinary substring match on one fieldsearchText(records ×3, role, ENGINEER)→ ×2 matching is case-insensitive in both directionssearchText(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.
export function searchText(records: readonly Readonly<Record<string, unknown>>[], fields: readonly string[], query: string): readonly Readonly<Record<string, unknown>>[]
| 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
import { searchText } from "#fune/collections.search-text@^1";
/** An open record, the manifest's `record`: a JSON-ish map whose shape is not known ahead of time. */
type SearchableRecord = Readonly<Record<string, unknown>>;
/** Largest integer JavaScript can hold exactly; beyond it the three languages disagree. */
const SAFE_INTEGER = 9007199254740991;
/**
* Fold A-Z to a-z and leave every other character alone.
*
* Deliberately not toLowerCase(): 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.
*/
export function foldAscii(text: string): string {
let out = "";
for (const ch of text) {
const code = ch.codePointAt(0) as number;
out += code >= 0x41 && code <= 0x5a ? String.fromCharCode(code + 32) : ch;
}
return out;
}
/**
* The searchable text of a value, or null 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.
*/
function searchableText(value: unknown): string | null {
if (typeof value === "string") return value;
if (typeof value === "boolean") return value ? "true" : "false";
if (typeof value === "number") {
if (!Number.isInteger(value) || Math.abs(value) > SAFE_INTEGER) return null;
return String(value);
}
return null;
}
/**
* 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.
*/
export function searchText(
records: readonly SearchableRecord[],
fields: readonly string[],
query: string,
): readonly SearchableRecord[] {
if (!Array.isArray(records)) {
throw new TypeError("searchText needs a list of records");
}
if (!Array.isArray(fields) || fields.some((f) => typeof f !== "string")) {
throw new TypeError("searchText needs a list of field names");
}
// 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.length === 0) {
throw new RangeError("searchText needs at least one field to search");
}
if (typeof query !== "string") {
throw new TypeError(`query must be a string, received ${query}`);
}
const needle = foldAscii(query);
if (needle.length === 0) return records.slice();
return records.filter((record) => {
if (record === null || record === undefined) return false;
for (const field of fields) {
const text = searchableText(record[field]);
if (text !== null && foldAscii(text).includes(needle)) return true;
}
return false;
});
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the TypeScript 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 collections.search-text
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./collections.search-text-1.0.0-typescript.fune, or fetch it from a terminal with fune pull collections.search-text@1.0.0:typescript.
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 |