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.
def search_text(records: Sequence[Mapping[str, Any]], fields: Sequence[str], query: str) -> List[Mapping[str, Any]]
| 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
from fune.collections.search_text import search_text # collections.search-text@^1
from typing import Any, List, Mapping, Optional, Sequence
# Largest integer JavaScript can hold exactly; beyond it the three languages disagree.
SAFE_INTEGER = 9007199254740991
def fold_ascii(text: str) -> str:
"""Fold A-Z to a-z and leave every other character alone.
Deliberately not str.lower(): 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.
"""
out = []
for ch in text:
code = ord(ch)
out.append(chr(code + 32) if 0x41 <= code <= 0x5A else ch)
return "".join(out)
def _searchable_text(value: Any) -> Optional[str]:
"""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.
"""
if isinstance(value, str):
return value
# bool before int: True would otherwise be searched as "1" here and as
# "true" in TypeScript and Rust.
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, int):
return str(value) if abs(value) <= SAFE_INTEGER else None
return None
def search_text(
records: Sequence[Mapping[str, Any]],
fields: Sequence[str],
query: str,
) -> List[Mapping[str, Any]]:
"""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.
"""
if isinstance(records, (str, bytes)) or not isinstance(records, (list, tuple)):
raise TypeError("search_text needs a list of records")
if isinstance(fields, (str, bytes)) or not isinstance(fields, (list, tuple)):
raise TypeError("search_text needs a list of field names")
if any(not isinstance(f, str) for f in fields):
raise TypeError("search_text 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 len(fields) == 0:
raise ValueError("search_text needs at least one field to search")
if not isinstance(query, str):
raise TypeError("query must be a string, received %r" % (query,))
needle = fold_ascii(query)
if needle == "":
return list(records)
matches: List[Mapping[str, Any]] = []
for record in records:
if not isinstance(record, dict):
continue
for field in fields:
text = _searchable_text(record.get(field))
if text is not None and needle in fold_ascii(text):
matches.append(record)
break
return matchesInstall
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Python 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 Python implementation. Install it without the registry with fune add ./collections.search-text-1.0.0-python.fune, or fetch it from a terminal with fune pull collections.search-text@1.0.0:python.
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 |