Functional Weave
Code in Python

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 field
  • search_text(records ×3, role, ENGINEER) → ×2 matching is case-insensitive in both directions
  • search_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]]
recordsrecord[]open JSON-ish maps; shapes may differ between records
fieldsstring[]the field names to search, at least one
querystringthe text to look for; empty matches everything
returnsrecord[]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
impl/python.py · 75 lines · open · raw
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 matches

Install

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
Download for Python collections.search-text-1.0.0-python.fune · 11,344 bytes sha256 29521da9eb456963852f09489aacf48db9589bf9415f0126af1c8fd7f5383252

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md1,293
impl/python.py2,920
impl/rust.rs3,084
impl/typescript.ts2,795
vectors.json4,730