The completion source behind the expression-editing component: predicator's own grammar vocabulary, plus the datamodel paths a host declares.
This module is pure and touches no LiveView, the same split
StatifierUI.Live.State gives the ops panes. StatifierUI.Live.ExpressionInput
renders what completions/2 returns; everything about what an author can
be offered is decided here, where it is testable without a browser and
without Phoenix.
Two sources, and only two
A completion is either a declared datamodel path - supplied by the caller,
because only the host knows its own datamodel - or a lexeme of the
predicator grammar, read from Predicator.Vocabulary (px-15q). Nothing is
invented here. An operator this module offered that the lexer does not accept
would be a second, drifting copy of the grammar, which is precisely the
duplication Predicator.Vocabulary was published to prevent.
The grammar half degrades
Predicator.Vocabulary is newer than the predicator releases this package
can resolve, so its absence is a supported state rather than a broken one: a
host on an older predicator gets its declared paths and no grammar entries,
and the component renders a plain input with a path list. The module is
reached through Application.get_env(:statifier_ui, :predicator_vocabulary, Predicator.Vocabulary) and guarded with Code.ensure_loaded?/1, so nothing
raises and nothing warns at compile time.
The picklist half
simple/2 answers a second question about a source string: not "what could
be typed next" but "can a row of dropdowns draw this at all". It reads
Predicator.Simple (px-84i), the upstream module that names the
picklist-renderable subset, and returns the clause rows a renderer walks.
The three answers Predicator.Simple.from_source/1 keeps apart are kept
apart here too, because an editor needs all three: source inside the subset,
a valid expression outside it, and text that is not an expression at all.
Collapsing the middle one into an error would tell an author their working
condition is broken.
Predicator.Simple is resolved the way Predicator.Vocabulary is, through
Application.get_env(:statifier_ui, :predicator_simple, Predicator.Simple),
but the guard on it is wider than a Code.ensure_loaded?/1:
simple_available?/0 also requires the resolved module to export
from_source/1, to_source/1, operators/1 and value_kind/1. A host on
an older predicator - or one that points that key at a module missing any of
the four - degrades every function this guard gates, exactly as
simple_available?/0's own documentation lists them: simple/2 answers
:outside for every source string, operators/1 answers [], and
source/2, value_source/2 and segments/1 answer :error. That is the
answer that makes the component fall back to its plain text input.
Shape
Every completion is a map with four keys:
:label- what a completion list shows ("len(...)","contains"):insert- the text written into the source at the caret:kind-"path","function", or the px category of a lexeme ("comparison","logical", ...), which is what a list groups by:detail- one line of prose, ornilwhen the source carries none
Examples
iex> StatifierUI.Expression.completions(["card.brand"])
...> |> Enum.find(&(&1.insert == "card.brand"))
%{label: "card.brand", insert: "card.brand", kind: "path", detail: "declared path"}
Summary
Types
One offer in a value dropdown, as the host declared it.
One offer: what to show, what to write, how to group it.
The connective joining a picklist's clause rows.
One entry in an operator dropdown, as Predicator.Simple.operators/1
answers it.
One row of the picklist: everything a renderer needs to draw a field / operator / value line, and nothing it would have to compute itself.
What kind of value a clause row holds, which is what decides its operator list and its value control.
Functions
Every completion available to an expression field: the declared paths first, then the grammar.
The subset a native <datalist> can usefully offer: the word-shaped
completions.
The operators a picklist offers beside a value of the given kind, read from
Predicator.Simple.operators/1.
The path segments a declared datamodel path parses to.
Classifies a source string against the picklist-renderable subset.
Whether the resolved predicator exposes a usable Predicator.Simple.
Writes a source string back from the rows simple/2 returned.
The values a host offers for one clause path, normalized.
The source text one clause value is written as, on its own.
Whether the resolved predicator exposes Predicator.Vocabulary.
Types
One offer in a value dropdown, as the host declared it.
@type completion() :: %{ label: String.t(), insert: String.t(), kind: String.t(), detail: String.t() | nil }
One offer: what to show, what to write, how to group it.
@type connective() :: :and | :or | nil
The connective joining a picklist's clause rows.
nil for a single row, which is joined to nothing - the same invariant
Predicator.Simple carries.
@type operator() :: %{ op: atom(), lexeme: String.t(), label: String.t(), detail: String.t() | nil }
One entry in an operator dropdown, as Predicator.Simple.operators/1
answers it.
:op is the atom a clause is built with and :lexeme is the source
spelling Predicator.Simple.to_source/1 writes for it - the two halves
that have to agree, and the reason a picklist can offer an operator
without spelling one itself. :label is the grammar's own display phrase
("is at least" for ">="), a UI string that is never stored, and
:detail its one-line description, or nil when Predicator.Vocabulary
is not resolvable.
@type row() :: %{ path: String.t(), segments: [tuple()], op: atom(), op_label: String.t(), value: term(), value_kind: value_kind(), value_source: String.t(), operators: [operator()], candidates: [candidate()] }
One row of the picklist: everything a renderer needs to draw a field / operator / value line, and nothing it would have to compute itself.
:segments and :value are Predicator.Simple's own structural forms, kept
so a renderer can hand an edited row straight back to
Predicator.Simple.to_ast/1; :path, :op_label, and :value_source are
the same three things spelled the way the source spells them.
:op_label is therefore a source spelling, which is the one place in this
module where "label" means the opposite of what it means next door: on
operator/0 :label is the grammar's display phrase and :lexeme is the
spelling. A dropdown draws operator/0's :label; :op_label is what
the expression carries.
@type value_kind() :: :integer | :float | :boolean | :string | :date | :datetime | :duration | :relative_date | {:list, value_kind() | nil}
What kind of value a clause row holds, which is what decides its operator list and its value control.
A list carries the kind of its members, or nil when it is empty.
These are the shapes a clause value takes, which is not the vocabulary
Predicator.Vocabulary.value_kinds/0 names: it has one :number where
this has :integer and :float, and no :relative_date at all.
operators/1 translates between the two, and nothing else needs to.
Functions
@spec completions( [String.t()], keyword() ) :: [completion()]
Every completion available to an expression field: the declared paths first, then the grammar.
candidates is the declared datamodel path list - what
StatifierBlocks.Datamodel.candidates/3 returns, arriving through the
expression_component seam as :candidates. Paths lead because they are the
ones an author cannot look up.
opts is passed through to Predicator.Vocabulary.functions/1, so a host
with its own Predicator.FunctionProvider modules offers exactly the
functions its own contexts will accept.
Examples
iex> StatifierUI.Expression.completions() |> Enum.any?(&(&1.insert == ">="))
true
iex> StatifierUI.Expression.completions([], builtins: false)
...> |> Enum.any?(&(&1.kind == "function"))
false
@spec datalist([completion()]) :: [completion()]
The subset a native <datalist> can usefully offer: the word-shaped
completions.
A <datalist> filters its options against the whole field value, and it
cannot insert at a caret. Offering "::" or "(" through one is noise, so
the no-JavaScript affordance carries paths, keywords, and function names and
leaves the symbol operators to the hook.
Examples
iex> StatifierUI.Expression.completions() |> StatifierUI.Expression.datalist()
...> |> Enum.any?(&(&1.insert == "::"))
false
@spec operators(value_kind()) :: [operator()]
The operators a picklist offers beside a value of the given kind, read from
Predicator.Simple.operators/1.
Eligibility is the grammar's answer, not this module's: which operators are
worth offering beside a date as opposed to a number is decided by the
:value_kinds predicator stamps on its own operator entries, so an operator
the lexer would reject - or one the grammar does not admit for this kind of
value - cannot be offered here. Until px-84i landed that function there was
a table here instead, and ADR-0007's 2026-09-04 amendment - accepted
2026-09-05 - named it as the one local exception; delegating closes it.
:op builds the clause, :lexeme is the spelling the expression will
carry, :label is the display phrase, and :detail is the grammar's
one-line description, or nil when Predicator.Vocabulary is not
resolvable.
Empty when simple_available?/0 is false, for the same reason simple/2
answers :outside: there is nothing truthful to offer. An atom the @spec
does not admit is handed to upstream unchanged rather than rejected here, so
one that happens to name a Predicator.Vocabulary kind - :number,
:list - gets the grammar's answer for that kind, and any other atom raises
there. The raise is upstream's own stance and the right one: an empty list
would say "no operators here" about a kind that does not exist.
Examples
iex> StatifierUI.Expression.operators(:boolean) |> Enum.map(& &1.op)
[:equal_equal, :strict_eq, :ne, :strict_ne, :contains]
iex> StatifierUI.Expression.operators({:list, :string}) |> Enum.map(& &1.lexeme)
["IN"]
iex> StatifierUI.Expression.operators(:integer) |> Enum.find(&(&1.op == :gte))
%{op: :gte, lexeme: ">=", label: "is at least", detail: "Greater than or equal to"}
The path segments a declared datamodel path parses to.
A picklist's field dropdown offers the paths a host declared, as strings.
Swapping a clause onto one of them needs that path in the structural form a
clause carries, and the only honest way to get there is predicator's own
parser - a path split on dots here would read account['tags'] wrong and
would be a second parser besides.
:error for a string that is not a path, and for every string when
Predicator.Simple is not resolvable.
Examples
iex> StatifierUI.Expression.segments("card.brand")
{:ok, [root: "card", property: "brand"]}
iex> StatifierUI.Expression.segments("amount >= 500")
:error
@spec simple( String.t(), keyword() ) :: {:ok, [row()], connective()} | :outside | {:error, term()}
Classifies a source string against the picklist-renderable subset.
Three answers, and they are three different questions:
{:ok, rows, connective}- inside the subset.rowsis onerow/0per clause andconnectiveisnilfor a single row,:andor:orfor two or more.:outside- a valid expression a picklist cannot draw. Offer the text editor; this is not an error.{:error, error}- the source does not parse, carrying predicator's own parse error with the position of the failure.
opts takes :value_candidates - a map from a clause's :path to the
values a host offers for it, as candidate/0 maps or bare strings. Only
the host knows its own value sets, so nothing is inferred here; a path with
no entry gets an empty list and the renderer falls back to a free-text value
control.
When simple_available?/0 is false - the resolved predicator has no
Predicator.Simple, or the module the :predicator_simple key points at
does not export all four of from_source/1, to_source/1, operators/1
and value_kind/1 - every source string answers :outside. That is a degraded
answer rather than a wrong one: the component renders the text input it
would render for an unsupported expression.
Examples
iex> {:ok, [row], nil} = StatifierUI.Expression.simple("plan == 'pro'")
iex> {row.path, row.op, row.value_source}
{"plan", :equal_equal, "'pro'"}
iex> {:ok, rows, connective} = StatifierUI.Expression.simple("status == 'active' AND amount >= 500")
iex> {length(rows), connective}
{2, :and}
iex> StatifierUI.Expression.simple("status == 'active' AND (amount >= 500 OR plan == 'pro')")
:outside
iex> {:error, error} = StatifierUI.Expression.simple("amount >= >=")
iex> error.position
{1, 11}
iex> {:ok, [row], nil} =
...> StatifierUI.Expression.simple("step in ['payment', 'review']",
...> value_candidates: %{"step" => ["payment", "review", "confirmation"]}
...> )
iex> {row.value_kind, Enum.map(row.candidates, & &1.value)}
{{:list, :string}, ["payment", "review", "confirmation"]}
@spec simple_available?() :: boolean()
Whether the resolved predicator exposes a usable Predicator.Simple.
The counterpart to vocabulary_available?/0, and the same distinction: a
component stamps it so a host can tell "this expression is outside the
subset" from "this predicator cannot answer the question".
Usable is four exports, not one loaded module: from_source/1,
to_source/1, operators/1 and value_kind/1 all have to be there. Under
the ~> 9.4 requirement that is inert, since every admitted predicator
carries all four; it is a host overriding :predicator_simple that the
condition measures, and a stub exporting three of the four reads as
unavailable - which is why the fourth joined the guard when this module
started asking upstream what kind a value is. Every
function this guard gates then degrades together: simple/2 to :outside,
operators/1 to [], and source/2, value_source/2 and segments/1 to
:error. A picklist is lost silently, so a host stubbing this module is
stubbing the whole surface.
Examples
iex> is_boolean(StatifierUI.Expression.simple_available?())
true
Writes a source string back from the rows simple/2 returned.
The write half of the same round trip: simple/2 reads source into rows and
this reads rows back into source, both through Predicator.Simple, so a
picklist never has to spell an operator, a quote, or a connective itself.
That is what makes the rendered dropdowns a view of the source text rather
than a second representation of the condition - a renderer edits a row's
:segments, :op, or :value, asks for the source, and stores the string
it gets back.
:error when Predicator.Simple is not resolvable, or when rows is
empty: there is no source string to write, and inventing one would be the
duplication this module exists to avoid.
Examples
iex> {:ok, rows, connective} =
...> StatifierUI.Expression.simple("status == 'active' AND amount >= 500")
iex> StatifierUI.Expression.source(rows, connective)
{:ok, "status == 'active' AND amount >= 500"}
iex> {:ok, [row], nil} = StatifierUI.Expression.simple("plan == 'pro'")
iex> StatifierUI.Expression.source([%{row | value: {:string, "free", :single}}], nil)
{:ok, "plan == 'free'"}
iex> StatifierUI.Expression.source([], nil)
:error
@spec value_candidates( %{optional(String.t()) => [candidate() | String.t()]}, String.t() ) :: [ candidate() ]
The values a host offers for one clause path, normalized.
candidates is simple/2's :value_candidates map. An entry may be a
candidate/0 map or a bare string, because a declared path list is
usually already a list of strings and making a caller wrap each one buys
nothing.
simple/2 folds this into every row it returns. It is public because a
renderer adding a new row has a path with no clause behind it yet, and
still needs its value list.
Examples
iex> StatifierUI.Expression.value_candidates(%{"step" => ["payment", "review"]}, "step")
[%{label: "payment", value: "payment"}, %{label: "review", value: "review"}]
iex> StatifierUI.Expression.value_candidates(%{}, "plan")
[]
The source text one clause value is written as, on its own.
source/2 covers every edit a renderer makes to a whole expression. This
covers the one case it cannot: a control that has to compose a value - a
free-text field that types into a quoted string, a multi-select that builds
a list - needs the spellings of the pieces, and asking for them here keeps
them coming from Predicator.Simple.to_source/1 rather than from a quoting
rule written a second time in JavaScript.
op is the operator the value sits beside, because :in is the one
operator whose right-hand side is a list.
Examples
iex> StatifierUI.Expression.value_source(:equal_equal, {:string, "pro", :single})
{:ok, "'pro'"}
iex> StatifierUI.Expression.value_source(:gte, {:integer, 500})
{:ok, "500"}
iex> StatifierUI.Expression.value_source(:in, {:list, [{:string, "payment", :single}]})
{:ok, "['payment']"}
@spec vocabulary_available?() :: boolean()
Whether the resolved predicator exposes Predicator.Vocabulary.
The component stamps this on the rendered input so a host - or a test - can tell "no grammar completions offered" from "grammar completions offered and none matched".
Examples
iex> is_boolean(StatifierUI.Expression.vocabulary_available?())
true