README.md

# Gusty

Lightweight Tailwind CSS class merging for Elixir. Zero dependencies, no compile-time overhead.

## Why Gusty?

In Tailwind, class order in the HTML attribute doesn't determine which style wins —
the stylesheet order does. This means `"p-4 p-2"` does not reliably apply `p-2`.
You need to deduplicate conflicting classes at the point where you build the string.

Gusty handles this by understanding Tailwind's class groups: it knows that `p-4` and
`p-2` conflict (both set padding), that `p-4` and `px-2` only partially conflict
(one sets all sides, the other only horizontal), and that `bg-red-500` and
`font-bold` don't conflict at all.

The previous Elixir solution for this — [Tails](https://github.com/zachdaniel/tails)
— relied on a compile-time list of 70,000+ pattern-match clauses, causing 8+
seconds of compile time and ~4 GB RAM usage. Gusty replaces this with a runtime
prefix trie built from ~500 lines of declarative group definitions. Lookup is O(k)
in the number of class segments, compile time is negligible.

## Installation

```elixir
def deps do
  [
    {:gusty, "~> 0.1"}
  ]
end
```

## Usage

### `Gusty.merge/2`

Merges two class strings. The second argument overrides the first.

```elixir
# Same group: last wins
Gusty.merge("p-4", "p-2")
#=> "p-2"

# Different groups: both kept
Gusty.merge("p-4", "m-2")
#=> "p-4 m-2"

# Longhand overrides shorthand: shorthand is dropped entirely
Gusty.merge("p-4", "px-2")
#=> "px-2"

# Shorthand overrides longhands: shorthand wins, longhands removed
Gusty.merge("px-2 py-4", "p-8")
#=> "p-8"

# Variants are respected — same group but different variants don't conflict
Gusty.merge("p-4", "hover:p-2")
#=> "p-4 hover:p-2"

# Same variant + same group: overrides
Gusty.merge("hover:p-4", "hover:p-2")
#=> "hover:p-2"

# Override conflicts: size-* replaces both w-* and h-*
Gusty.merge("w-4 h-4", "size-8")
#=> "size-8"
```

Both arguments also accept lists (passed through `Gusty.classes/1` first):

```elixir
Gusty.merge(["p-4", "m-2"], "p-2")
#=> "m-2 p-2"
```

### `Gusty.classes/1`

Builds a class string from a mixed list of strings and conditionals. Classes are merged left-to-right, so later entries win on conflicts.

```elixir
Gusty.classes("p-4 mt-2")
#=> "p-4 mt-2"

Gusty.classes(["p-4", "mt-2"])
#=> "p-4 mt-2"

# Conflicts are resolved: last wins
Gusty.classes(["p-4", "p-2"])
#=> "p-2"

# Longhand overrides shorthand: shorthand is dropped
Gusty.classes(["p-4", "px-2"])
#=> "px-2"

# Keyword lists: key is the class, value is the condition
Gusty.classes(["p-4", [hidden: true, "font-bold": false]])
#=> "p-4 hidden"

# Nested lists and tuples work too
Gusty.classes(["mt-1 mx-2", ["pt-2": true, "pb-4": false]])
#=> "mt-1 mx-2 pt-2"
```

### `Gusty.remove/2`

Removes an exact class from a class string.

```elixir
Gusty.remove("p-4 mt-2 font-bold", "mt-2")
#=> "p-4 font-bold"
```

### `~t` sigil

A string sigil that resolves conflicts in a literal class string. Only use literal class names — Tailwind's scanner must be able to find all class names statically.
Interpolated values will not be included in the generated stylesheet.

```elixir
import Gusty

~t"p-4 mt-2"
#=> "p-4 mt-2"

~t"p-4 p-2"
#=> "p-2"

~t"p-4 px-2"
#=> "px-2"
```

### `remove:` prefix

The `remove:` prefix in the override string removes a class by exact match without adding anything:

```elixir
Gusty.merge("font-bold text-black", "remove:font-bold grid")
#=> "text-black grid"
```

`remove:*` removes all base classes:

```elixir
Gusty.merge("font-bold text-black", "remove:* grid")
#=> "grid"
```

## Ambiguous classes

Several Tailwind prefixes map to more than one CSS property group. Gusty disambiguates them by inspecting the value:

| Prefix     | Possible groups                 | Resolution                                                                |
| ---------- | ------------------------------- | ------------------------------------------------------------------------- |
| `text-*`   | `font_size` or `text_color`     | t-shirt sizes (`sm`, `lg`, `2xl`…) → font size; color values → text color |
| `border-*` | `border_w` or `border_color`    | numbers/lengths → width; color values → color                             |
| `ring-*`   | `ring_w` or `ring_color`        | numbers → width; color values → color                                     |
| `shadow-*` | `shadow_size` or `shadow_color` | t-shirt sizes / `none` / `inner` → size; color values → color             |
| `stroke-*` | `stroke_w` or `stroke_color`    | numbers → width; color values → color                                     |

```elixir
# text-sm (font size) and text-blue-500 (text color) don't conflict
Gusty.merge("text-sm text-blue-500", "text-lg")
#=> "text-blue-500 text-lg"
```

## Variants

Variants (`hover:`, `focus:`, `md:`, `dark:`, etc.) are extracted from each class and used as a conflict scope. Two classes only conflict if they belong to the same group **and** have the same set of variants. Variant order within a class does not affect conflict detection.

```elixir
# md:hover: and hover:md: have the same variant set — they conflict
Gusty.merge("md:hover:p-4", "hover:md:p-2")
#=> "hover:md:p-2"

# Different variant sets — no conflict
Gusty.merge("hover:p-4", "focus:p-2")
#=> "hover:p-4 focus:p-2"
```

## Tailwind class prefix

If you use Tailwind's `prefix` option (e.g., `prefix: 'tw-'` in `tailwind.config.js`), configure Gusty to strip and re-apply it automatically:

```elixir
# config/config.exs
config :gusty, :class_prefix, "tw-"
```

```elixir
Gusty.merge("tw-p-4", "tw-p-2")
#=> "tw-p-2"

Gusty.merge("tw-p-4", "tw-px-2")
#=> "tw-px-2"
```

## Configuration

All options are set via `Application` config:

```elixir
# config/config.exs

# Tailwind class prefix (default: none)
config :gusty, :class_prefix, "tw-"

# Additional color names for disambiguation (default: [])
config :gusty, :custom_colors, ["primary", "secondary", "brand-blue"]

# Classes that are never merged — always kept as-is (default: [])
config :gusty, :no_merge_classes, ["custom-utility"]

# Enable directional decomposition (default: false — see below)
config :gusty, :decompose, true
```

## Directional decomposition

Tailwind's shorthand utilities like `p-*` expand to set all four sides. When a longhand like `px-2` overrides a shorthand like `p-4`, there are two possible strategies:

**Drop (default):** The shorthand is removed and the longhand takes over. Simple, safe, and predictable — Tailwind's scanner only ever sees class names that are literally present in your source.

```elixir
Gusty.merge("p-4", "px-2")
#=> "px-2"
```

**Decompose (opt-in):** The shorthand is split into its non-conflicting children and the override is applied. The result preserves more information, but the decomposed class names (e.g., `py-4`) are generated at runtime — Tailwind's static scanner cannot see them and will not include them in the stylesheet unless they appear elsewhere in your source.

```elixir
# config/config.exs
config :gusty, decompose: true
```

```elixir
Gusty.merge("p-4", "px-2")
#=> "py-4 px-2"   # <!> py-4 must exist in Tailwind's safelist or source scan
```

Only enable decomposition if you are using Tailwind's [safelist](https://tailwindcss.com/docs/content-configuration#safelisting-classes) or can otherwise guarantee all decomposed class names are present in the stylesheet.

Note that the reverse direction — a shorthand overriding existing longhands — always works safely regardless of this setting, because the shorthand class was already present in your source:

```elixir
Gusty.merge("px-2 py-4", "p-8")
#=> "p-8"
```

## How it works

Gusty builds a prefix trie from declarative group definitions at runtime. Each Tailwind utility class maps to a group ID (e.g., `:p`, `:px`, `:bg_color`, `:font_size`). When merging:

1. Both class strings are parsed into structured maps (variants, base, modifiers, arbitrary values).
2. Each class is classified to a group via trie lookup.
3. For each override class, any base class in the same group **and** with the same variant set is removed.
4. If a base class is a shorthand ancestor of the override's group (e.g., `p-4` is an ancestor of `px`), it is dropped (or decomposed if `config :gusty, decompose: true`).
5. The resulting class list is reconstructed into a string.