src/textmetrics@search.erl

-module(textmetrics@search).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/textmetrics/search.gleam").
-export([did_you_mean/3, closest/3, rank_jaro_winkler/3]).
-export_type([ranked/0]).

-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.

?MODULEDOC(
    " Convenience helpers for spell-correction-style search built on\n"
    " [`distance`](textmetrics/distance.html) and\n"
    " [`similarity`](textmetrics/similarity.html).\n"
    "\n"
    " Both functions are deterministic: ties are broken by the\n"
    " candidates' original input order.\n"
).

-type ranked() :: {ranked, binary(), float()}.

-file("src/textmetrics/search.gleam", 29).
?DOC(
    " Return candidates within `max_distance` Levenshtein graphemes of\n"
    " `query`, sorted ascending by distance. Empty list when nothing\n"
    " matches or when `candidates` is empty.\n"
    "\n"
    " Ties on distance are broken by the candidate's position in\n"
    " `candidates`.\n"
).
-spec did_you_mean(binary(), list(binary()), integer()) -> list(binary()).
did_you_mean(Query, Candidates, Max_distance) ->
    _pipe = Candidates,
    _pipe@1 = gleam@list:index_map(
        _pipe,
        fun(C, I) -> {C, textmetrics@distance:levenshtein(Query, C), I} end
    ),
    _pipe@2 = gleam@list:filter(
        _pipe@1,
        fun(T) ->
            {_, D, _} = T,
            D =< Max_distance
        end
    ),
    _pipe@3 = gleam@list:sort(
        _pipe@2,
        fun(A, B) ->
            {_, Da, Ia} = A,
            {_, Db, Ib} = B,
            case gleam@int:compare(Da, Db) of
                eq ->
                    gleam@int:compare(Ia, Ib);

                O ->
                    O
            end
        end
    ),
    gleam@list:map(
        _pipe@3,
        fun(T@1) ->
            {C@1, _, _} = T@1,
            C@1
        end
    ).

-file("src/textmetrics/search.gleam", 62).
?DOC(
    " Single closest candidate within `max_distance` Levenshtein\n"
    " graphemes of `query`. Returns `Error(Nil)` when no candidate is\n"
    " close enough or when `candidates` is empty.\n"
    "\n"
    " This is the convenience form of [`did_you_mean`](#did_you_mean) for\n"
    " the dominant CLI use case (\"Unknown command. Did you mean `X`?\").\n"
    " Ties on distance are broken by the candidate's position in\n"
    " `candidates` — the first qualifying candidate wins.\n"
).
-spec closest(binary(), list(binary()), integer()) -> {ok, binary()} |
    {error, nil}.
closest(Query, Candidates, Max_distance) ->
    case did_you_mean(Query, Candidates, Max_distance) of
        [First | _] ->
            {ok, First};

        [] ->
            {error, nil}
    end.

-file("src/textmetrics/search.gleam", 79).
?DOC(
    " Rank `candidates` by Jaro-Winkler similarity (Winkler-1990\n"
    " defaults) descending, returning up to `top_n` [`Ranked`](#Ranked)\n"
    " records.\n"
    "\n"
    " Ties on similarity are broken by the candidate's position in\n"
    " `candidates`. When `top_n <= 0` returns an empty list.\n"
).
-spec rank_jaro_winkler(binary(), list(binary()), integer()) -> list(ranked()).
rank_jaro_winkler(Query, Candidates, Top_n) ->
    _pipe = Candidates,
    _pipe@1 = gleam@list:index_map(
        _pipe,
        fun(C, I) -> {C, textmetrics@similarity:jaro_winkler(Query, C), I} end
    ),
    _pipe@2 = gleam@list:sort(
        _pipe@1,
        fun(A, B) ->
            {_, Sa, Ia} = A,
            {_, Sb, Ib} = B,
            case gleam@float:compare(Sb, Sa) of
                eq ->
                    gleam@int:compare(Ia, Ib);

                O ->
                    O
            end
        end
    ),
    _pipe@3 = gleam@list:take(_pipe@2, Top_n),
    gleam@list:map(
        _pipe@3,
        fun(T) ->
            {C@1, S, _} = T,
            {ranked, C@1, S}
        end
    ).