# `Flicker.CursorContext`
[🔗](https://github.com/team-alembic/flicker/blob/v0.1.0/lib/flicker/cursor_context.ex#L1)

The cursor-context state machine ([Spec 003](https://github.com/team-alembic/flicker/blob/main/docs/specs/spec-003-faceted-search.md)) — a pure function that,
given typed input, a cursor position, and the active facet registry, says
what the user is typing *right now*: a facet key, a facet value, or free
text. The dropdown reads this to pick its result source (key suggestions,
a facet's value picklist / nested search, or plain free-text handling)
and its keyboard behaviour, without needing a browser to test any of it.

This module has no LiveView dependency — `classify/3` is deterministic and
never raises, so it is exercised directly by unit and property tests
against arbitrary strings and cursor positions.

## Cursor position

`cursor` is a **codepoint offset** into `input` (`0` is before the first
character, `String.length(input)` is after the last) — the same unit
`String.length/1` and `String.slice/3` use. A caller wiring this to a real
`<input>`'s `selectionStart` (a UTF-16 code-unit offset) converts between
the two with `from_utf16_offset/2` (and parses the raw event payload with
`parse_selection_start/1` first — see `Flicker.Components.Search`/
`Flicker.Components.Select`, or the higher-level `Flicker.FacetSuggest.classify/3`,
which does both steps for you); out-of-range values (negative, or past the
end of `input`) are clamped rather than raising.

## States

  * `{:key, prefix}` — the cursor sits inside a token before any facet
    operator has been typed (`stat|` inside `stat`, or a plain free-text
    word not yet followed by `:`/`>`/etc.). `prefix` is the token's text
    from its start up to the cursor — filter facet-key suggestions by it.
  * `{:value, facet, prefix}` — the token up to the operator resolves to a
    known facet (`status:` → `%Flicker.Facet{key: :status}`) and the
    operator itself is legal for it (`:` is always legal; a symbol
    operator like `>=` must be in the facet's `:operators`). `prefix` is
    the value text typed so far (quote/escape-unwrapped if the value is
    quoted), truncated at the cursor — filter the facet's picklist /
    nested search by it. The value need not cast cleanly yet — that
    validation belongs to `Flicker.Query.parse/2`, not here, or the state
    would never fire while an enum value is still mid-type.
  * `:text` — the token up to the operator does *not* resolve to a known,
    legal facet (unknown key, or a symbol operator the facet doesn't
    allow) and the cursor sits at or past that operator — this token is
    headed for free text once `Flicker.Query.parse/2` sees it, and there
    is no facet to drive value suggestions from.

## Trigger character

Passing a `Flicker.Trigger` as the optional fourth argument (Spec 024) gates
`{:key, _}` behind an explicit gesture: only a token opening with a trigger
grapheme (`@stat`) reports a key prefix, and it reports it *without* the
trigger, so the completion is the ordinary canonical token. Every other bare
word is `:text`. `{:value, _, _}` is untouched — a facet typed out in full is
still recognised, since gating recognition would break pasted and
URL-restored queries.

# `prefix`

```elixir
@type prefix() :: String.t()
```

The text of the token from its start up to the cursor.

# `t`

```elixir
@type t() :: {:key, prefix()} | {:value, Flicker.Facet.t(), prefix()} | :text
```

What the cursor is currently positioned to type.

# `classify`

```elixir
@spec classify(String.t(), integer(), [Flicker.Facet.t()], Flicker.Trigger.t() | nil) ::
  t()
```

Classifies what `cursor` is positioned to type in `input`, given the
active `facets`.

Pure and total: any `input` (empty, malformed, arbitrary Unicode) and any
`cursor` (including out-of-range integers, which are clamped to
`0..String.length(input)`) produce one of the three states in `t/0` —
never an exception.

## Examples

    iex> Flicker.CursorContext.classify("stat", 4, [%Flicker.Facet{key: :status}])
    {:key, "stat"}

    iex> Flicker.CursorContext.classify("status:", 7, [%Flicker.Facet{key: :status}])
    {:value, %Flicker.Facet{key: :status}, ""}

    iex> Flicker.CursorContext.classify("status:active", 10, [%Flicker.Facet{key: :status}])
    {:value, %Flicker.Facet{key: :status}, "act"}

    iex> Flicker.CursorContext.classify("bogus:active", 13, [])
    :text

    iex> Flicker.CursorContext.classify("", 0, [])
    {:key, ""}

With a `Flicker.Trigger` (Spec 024), `{:key, _}` fires only for a token that
opens with a trigger grapheme — the trigger itself is dropped from the
reported prefix, and the facets considered are that trigger's own scope:

    iex> facets = [%Flicker.Facet{key: :status}]
    ...> Flicker.CursorContext.classify("@stat", 5, facets, %{"@" => :all})
    {:key, "stat"}

    iex> Flicker.CursorContext.classify("stat", 4, [%Flicker.Facet{key: :status}], %{"@" => :all})
    :text

...but a facet typed out in full is still *recognised*, against the whole
registry — a pasted or URL-restored query must not stop working because a
trigger is configured:

    iex> facets = [%Flicker.Facet{key: :status}]
    ...> Flicker.CursorContext.classify("status:acti", 11, facets, %{"@" => :all})
    {:value, %Flicker.Facet{key: :status}, "acti"}

# `from_utf16_offset`

```elixir
@spec from_utf16_offset(String.t(), non_neg_integer()) :: non_neg_integer()
```

Converts `utf16_offset` — a UTF-16 code-unit offset, the unit a real
`<input>`'s `selectionStart` reports — into the codepoint offset
`classify/3`/`token_start/2` expect (see the "Cursor position" section
above). A no-op for the common case: every codepoint Flicker's own
facet grammar cares about (ASCII, plus the Unicode letters/digits
`key_char?/1` allows in a key) is exactly one UTF-16 code unit. Only a
codepoint outside the Basic Multilingual Plane — an emoji typed into a
free-text facet value, say — is encoded as a two-unit surrogate pair in
UTF-16 and needs collapsing to the single codepoint it is.

## Examples

    iex> Flicker.CursorContext.from_utf16_offset("status:active", 7)
    7

    iex> Flicker.CursorContext.from_utf16_offset("😀status", 3)
    2

# `parse_selection_start`

```elixir
@spec parse_selection_start(String.t() | integer() | nil) :: non_neg_integer() | nil
```

Parses a `selectionStart` value out of an event payload the colocated
hook sends (Spec 003's cursor-tracking follow-up, now closed). Every
value pushed over the LiveView socket arrives as a string (`phx-value-*`
attributes and pushed event payloads are always strings), so this is
the one place both `Flicker.Components.Search` and
`Flicker.Components.Select` parse it back into an integer — or `nil`
for anything that isn't a clean non-negative integer, which is exactly
what `Flicker.FacetSuggest.classify/3` treats as "no position reported
yet" and falls back to the end of the text for (the dead-render / very
first keystroke case, before the hook's first event has landed).

## Examples

    iex> Flicker.CursorContext.parse_selection_start("7")
    7

    iex> Flicker.CursorContext.parse_selection_start(nil)
    nil

    iex> Flicker.CursorContext.parse_selection_start("nope")
    nil

# `token_bounds`

```elixir
@spec token_bounds(String.t(), integer()) :: {non_neg_integer(), non_neg_integer()}
```

The `{start, stop}` codepoint bounds of the token containing `cursor` —
`token_start/2` is `elem(token_bounds(input, cursor), 0)`. `stop` is that
same token's end offset (one past its last codepoint), or `start` itself
when the cursor sits in a whitespace gap between tokens / empty input
(matching `token_start/2`'s own empty-virtual-token behaviour there).

Used by `Flicker.FacetSuggest.replace_current_token/3` to know not just
where a chosen suggestion's replacement text starts, but where the token
being completed *ends* — so completing a token the cursor has been moved
back into (mid-token editing, Spec 003's cursor-tracking follow-up, now
closed) never clobbers whatever free text or other facet tokens follow
it.

## Examples

    iex> Flicker.CursorContext.token_bounds("foo bar stat", 12)
    {8, 12}

    iex> Flicker.CursorContext.token_bounds("status:acti tier:legendary", 10)
    {0, 11}

# `token_start`

```elixir
@spec token_start(String.t(), integer()) :: non_neg_integer()
```

The start offset (codepoint index) of the token containing `cursor`,
using the same quote-aware tokenizer `classify/3` does — a double-quoted
span counts as one token even when it contains whitespace (`worker:"Casey
N|` is a single token starting at `0`, not split at the space before
`N`).

Used by `Flicker.FacetSuggest.replace_current_token/3` to find the
boundary to splice a chosen suggestion in at, so replacing a token never
clips mid-quote.

## Examples

    iex> Flicker.CursorContext.token_start("foo bar stat", 12)
    8

    iex> Flicker.CursorContext.token_start(~s(worker:"Casey N), 16)
    0

---

*Consult [api-reference.md](api-reference.md) for complete listing*
