Flicker.FacetSuggest (Flicker v0.1.0)

Copy Markdown View Source

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

suggestion()

@type suggestion() :: Flicker.Result.t()

A facet suggestion — a Flicker.Result tagged for token insertion.

Functions

classify(text, facets, cursor \\ nil, trigger \\ nil)

@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.

enum_value_suggestions(facet, prefix)

@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 by prefix against the value's own key or label (case-insensitive substring).
  • :boolean — the fixed pair true / false, filtered by prefix (case-insensitive) against each literal's own text — "f" suggests only false.

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).

key_suggestions(prefix, facets)

@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, ""}.

replace_current_token(text, replacement, cursor \\ nil)

@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"

resolve_facets(arg1)

@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 by Flicker.Providers.AshResource.facets/1.
  • source: set (Tier 2) to a provider that implements the optional Flicker.Provider.facets/0 callback — that provider's own facet registry.
  • facets: already a list of Flicker.Facet structs — used as-is (a hand-built registry, no resource/provider introspection).
  • anything else — [] (no facets configured).

scoped_facets(text, facets, cursor, trigger)

@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]