The cursor-context state machine (Spec 003) — 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|insidestat, or a plain free-text word not yet followed by:/>/etc.).prefixis 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).prefixis 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 toFlicker.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 onceFlicker.Query.parse/2sees 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.
Summary
Types
The text of the token from its start up to the cursor.
What the cursor is currently positioned to type.
Functions
Classifies what cursor is positioned to type in input, given the
active facets.
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.
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).
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).
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).
Types
@type prefix() :: String.t()
The text of the token from its start up to the cursor.
@type t() :: {:key, prefix()} | {:value, Flicker.Facet.t(), prefix()} | :text
What the cursor is currently positioned to type.
Functions
@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"}
@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
@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
@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}
@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