Shared facet-suggestion machinery (Spec 003) —
the pure functions both Flicker.search/1 and the facets attr on
Flicker.select/1 call to turn a Flicker.CursorContext classification
into a suggestion list, and to splice a chosen suggestion back into the
typed text.
This is the "same machinery" the spec calls for: neither component
reimplements key/value suggestion filtering or token replacement, they
both drive this module off Flicker.CursorContext.classify/3.
Every suggestion is a Flicker.Result — reusing the same display struct
the record-search listbox already renders — tagged
meta: %{flicker_facet: true, insert: text}: :insert is the literal
text that replaces the current token when the suggestion is chosen
("status:" for a key suggestion, "status:active " for a value one).
A caller distinguishes a facet suggestion from an ordinary record result
by checking result.meta[:flicker_facet].
Summary
Types
A facet suggestion — a Flicker.Result tagged for token insertion.
Functions
Classifies text against facets at cursor — a UTF-16 code-unit
offset, exactly what a real <input>'s selectionStart reports.
Value suggestions for facet's closed value set, filtered by prefix.
Facet-key suggestions matching prefix — 'stat' → 'status:'.
Runs the nested, actor-scoped search for a relationship facet's values
(Spec 003) — a plain Flicker.Providers.AshResource search over
facet.related.resource, filtered by prefix, honouring actor and
tenant (ADR-004): a record the actor can't read never appears as a
facet-value suggestion.
Splices replacement in for the token at cursor (a UTF-16 code-unit
offset, same convention as classify/3) — the token boundary is found
the same quote-aware way CursorContext.classify/3 finds it
(CursorContext.token_bounds/2), so a token that is a double-quoted
span containing whitespace (worker:"Casey N) is replaced whole rather
than just its trailing word, and anything after the replaced token
(free text, or another facet token) survives untouched — completing a
token the cursor was moved back into (mid-token editing, Spec 003's
cursor-tracking follow-up, now closed) never clobbers what follows it.
Resolves the facet registry Flicker.select/1's facets: attr and
Flicker.search/1 share.
The facets in play for the token under cursor — the whole registry, or just
the scope of the trigger that token opens with (Spec 024).
Types
@type suggestion() :: Flicker.Result.t()
A facet suggestion — a Flicker.Result tagged for token insertion.
Functions
@spec classify( String.t(), [Flicker.Facet.t()], non_neg_integer() | nil, Flicker.Trigger.t() | nil ) :: Flicker.CursorContext.t()
Classifies text against facets at cursor — a UTF-16 code-unit
offset, exactly what a real <input>'s selectionStart reports.
Both components' colocated hooks report the real caret position on
every keystroke/click/selection change (Spec 003's cursor-tracking
follow-up, now closed) — cursor is that reported value, converted to
the codepoint offset Flicker.CursorContext.classify/3 expects via
Flicker.CursorContext.from_utf16_offset/2.
cursor defaults to (and an explicit nil also falls back to) the end
of text — the documented fallback for the dead render / very first
event, before the hook has reported a real position yet. Mid-token
editing (moving the caret back into an already-typed token) is
otherwise classified exactly where the caret sits, not "at the end" —
Flicker.CursorContext itself has always supported arbitrary cursor
positions and is exercised at every position by its own unit/property
tests; this function is what threads a real one into it.
trigger is a Flicker.Trigger (Spec 024) or nil — with one configured,
facet-key suggestions only fire for a token opening with a trigger grapheme.
@spec enum_value_suggestions(Flicker.Facet.t(), String.t()) :: [suggestion()]
Value suggestions for facet's closed value set, filtered by prefix.
Two facet types have a closed set to suggest from (Spec 003's type table):
:enum— the picklist in:values, filtered byprefixagainst the value's own key or label (case-insensitive substring).:boolean— the fixed pairtrue/false, filtered byprefix(case-insensitive) against each literal's own text —"f"suggests onlyfalse.
Returns [] for any other facet type — a plain string/numeric/date
facet has no closed picklist (a relationship facet's values come from
related_search/4 instead).
@spec key_suggestions(String.t(), [Flicker.Facet.t()]) :: [suggestion()]
Facet-key suggestions matching prefix — 'stat' → 'status:'.
Filters facets to those whose key starts with prefix (case-sensitive,
matching how facet keys are typed) and maps each to a suggestion whose
:insert is "<key>:" — landing the cursor right after the operator, so
the very next classification is {:value, facet, ""}.
@spec replace_current_token(String.t(), String.t(), non_neg_integer() | nil) :: String.t()
Splices replacement in for the token at cursor (a UTF-16 code-unit
offset, same convention as classify/3) — the token boundary is found
the same quote-aware way CursorContext.classify/3 finds it
(CursorContext.token_bounds/2), so a token that is a double-quoted
span containing whitespace (worker:"Casey N) is replaced whole rather
than just its trailing word, and anything after the replaced token
(free text, or another facet token) survives untouched — completing a
token the cursor was moved back into (mid-token editing, Spec 003's
cursor-tracking follow-up, now closed) never clobbers what follows it.
cursor defaults to (and an explicit nil also falls back to) the end
of text, same fallback classify/3 documents — every existing caller
that picks a suggestion right after typing it (the overwhelming common
case) needs nothing else.
Examples
iex> Flicker.FacetSuggest.replace_current_token("status:acti", "status:active ")
"status:active "
iex> Flicker.FacetSuggest.replace_current_token("foo bar stat", "status:")
"foo bar status:"A Spec 024 trigger grapheme is part of the token, so it disappears as a
consequence of replacing the token — there is no stripping step, and no way
for @ to end up in the query:
iex> Flicker.FacetSuggest.replace_current_token("@stat", "status:")
"status:"
iex> Flicker.FacetSuggest.replace_current_token(~s(worker:"Casey N), "worker:123 ")
"worker:123 "
iex> Flicker.FacetSuggest.replace_current_token(
...> "status:acti tier:legendary",
...> "status:active ",
...> 10
...> )
"status:active tier:legendary"
@spec resolve_facets(map()) :: [Flicker.Facet.t()]
Resolves the facet registry Flicker.select/1's facets: attr and
Flicker.search/1 share.
Precedence:
resource:set (Tier 1) —facets:is a list of bare keys /{key, overrides}pairs, expanded byFlicker.Providers.AshResource.facets/1.source:set (Tier 2) to a provider that implements the optionalFlicker.Provider.facets/0callback — that provider's own facet registry.facets:already a list ofFlicker.Facetstructs — used as-is (a hand-built registry, no resource/provider introspection).- anything else —
[](no facets configured).
@spec scoped_facets( String.t(), [Flicker.Facet.t()], non_neg_integer() | nil, Flicker.Trigger.t() | nil ) :: [Flicker.Facet.t()]
The facets in play for the token under cursor — the whole registry, or just
the scope of the trigger that token opens with (Spec 024).
Flicker.CursorContext.classify/4 applies this scoping internally to decide
what the cursor is typing; a caller needs it again to build the suggestion
list itself, and getting it from here rather than from the returned state
keeps Flicker.CursorContext.t/0 unchanged and every existing case over it
exhaustive.
Falls back to the whole registry when no trigger is configured, or when the
current token doesn't open with one — in the latter case the caller is in
{:value, _, _} or :text, neither of which lists keys.
Examples
iex> facets = [Flicker.Facet.new(key: :worker), Flicker.Facet.new(key: :tag)]
...>
...> Flicker.FacetSuggest.scoped_facets("@wor", facets, nil, %{"@" => [:worker]})
...> |> Enum.map(& &1.key)
[:worker]
iex> facets = [Flicker.Facet.new(key: :worker), Flicker.Facet.new(key: :tag)]
...>
...> Flicker.FacetSuggest.scoped_facets("wor", facets, nil, %{"@" => [:worker]})
...> |> Enum.map(& &1.key)
[:worker, :tag]