# `MishkaChelekom.CmsBundle.Discriminators`
[🔗](https://github.com/mishka-group/mishka_chelekom/blob/v0.0.9/lib/mishka_chelekom/cms_bundle/discriminators.ex#L1)

Convert chelekom `.eex` source into per-helper **axis-value clauses**
that the MishkaCMS installer uses to narrow installs (e.g. base-only)
without guessing positional-arg semantics.

Two conceptual stages, exposed as one combined surface plus a few
primitives:

  1. **Index build** (`build_index/1`) — walk the raw `.eex`,
     identify every `defp NAME(args) do` declaration, and record the
     enclosing `<%= if … do %>` chain. Innermost wrapper comes LAST
     in the list so the consumer ANDs them.

  2. **Clause conversion** (`from_conditions/1`) — convert a list of
     condition ASTs into a flat `[%{"axis" => …, "values" => [...]}]`
     list the installer can apply directly.

Both stages use real parsers — `EEx.tokenize/1` for the template,
`Code.string_to_quoted/1` + `Macro.prewalk/3` for `defp` discovery
and condition shapes — so the chain is predictable and resilient
to whitespace / comment / paren noise.

## Why

Chelekom's `.eex` source uses gating like:

    <%= if "base" in @variant and ("red" in @color or "black" in @color) do %>
      defp color_variant("base", "red"),   do: "..."
      defp color_variant("base", "black"), do: "..."
    <% end %>

The exporter EEx-evals the template with all options enabled, so the
bundle's `helpers[]` array contains the wrapped defps without their
wrapping context. This module recovers the context by re-parsing
the **raw .eex** (without evaluating).

## Recognised condition shapes

    "X" in @axis                    → [{axis, [X]}]
    @axis in [X, Y]                 → [{axis, [X, Y]}]
    "X" in @axis or "Y" in @axis    → [{axis, [X, Y]}]
    is_nil(@axis) or "X" in @axis   → [{axis, [X]}]   (is_nil branch
                                       is "no filter applied", so
                                       when the user DOES filter,
                                       only the value path matters)
    A and B                         → discriminators(A) ++ discriminators(B)
    not A                           → ignored
    everything else                 → ignored

When the same axis appears in multiple ANDed wrappers (different
`<%= if %>` blocks), value sets are **intersected** — both
conditions must hold.

# `axis_clause`

```elixir
@type axis_clause() :: %{required(String.t()) =&gt; any()}
```

# `condition_ast`

```elixir
@type condition_ast() :: Macro.t()
```

# `index`

```elixir
@type index() :: %{required(signature()) =&gt; [condition_ast()]}
```

# `signature`

```elixir
@type signature() :: {String.t(), String.t()}
```

# `build_index`

```elixir
@spec build_index(String.t()) :: index()
```

Parse `eex_source` and return the helper-condition index.

    %{{helper_name, normalized_arg_pattern} => [condition_ast, ...]}

Returns `%{}` on tokenization or parse failure (caller should fall
back to un-discriminated emission).

# `from_condition`

```elixir
@spec from_condition(condition_ast()) :: [axis_clause()]
```

Convert a single condition AST into a flat list of axis clauses.

# `from_conditions`

```elixir
@spec from_conditions([condition_ast()]) :: [axis_clause()]
```

Convert a list of condition ASTs into a flat list of axis clauses.
Same-axis clauses produced by different conditions are MERGED via
intersection (AND semantics — both wrappers must pass).

# `normalize_args`

```elixir
@spec normalize_args(String.t()) :: String.t()
```

Collapse whitespace in an argument-list string + trim. Both this
module and the exporter call this on their respective sources so
`(name, args)` signatures match exactly.

---

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