Functional Weave
Code in Python

collections.search-text@1.0.0

impl/rust.rs

3,084 bytes · the Rust implementation · view raw

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()))
}