Functional Weave
Code in Rust

collections.search-text@1.0.0

impl/typescript.ts

2,795 bytes · the TypeScript implementation · view raw

/** 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;
  });
}