# `PiiCatalog.Scanner`
[🔗](https://github.com/signalvaultio/pii_catalog/blob/v0.1.0/lib/pii_catalog/scanner.ex#L1)

A compiled set of patterns, ready to scan text. Build one with
`PiiCatalog.compile/1` and keep it: compiling every pattern takes about
as long as scanning a few kilobytes of text, which is worth paying once rather
than per call. A scanner is an immutable value, so it can be kept in a process, an ETS
table or `:persistent_term`. Build it at runtime rather than in a module
attribute: from OTP 28 a compiled regex is a reference, which cannot be
stored at compile time.

## How a text is scanned

The regex engine pays a cost at every position of the text for each pass,
so scanning patterns one after another costs one such pass each. Most
catalog patterns start the same way: no ASCII word character before, then
a digit or a capital letter. Those share one pass. Patterns of your own
never do: each is scanned in a pass of its own, so it cannot change what
the catalog patterns find, and an error from the engine on it names it
alone. At each position where
such a match can start, each of them is tried as a lookahead with a named
capture, and the position is reported only if one of them matched.

For each pattern the result is what `Regex.scan/3` gives on its own
regex: matches left to right, none starting inside the previous one. A
pattern scanned in the shared pass matches at a position exactly when its
own regex does, so the only step left is dropping a match that starts
inside the previous one.

A catalog pattern shares the pass when `shared?/1` is true: its regex starts with
`(?<![A-Za-z0-9_])`, has no flags, has no `|` outside parentheses, and
uses none of `\K`, a backtracking verb such as `(*SKIP)` (any `(*` in
its source), a named group, a backreference (`\1` to `\9`, `\g`,
`\k`, `(?P=`), a subroutine call (`(?1)`, `(?+1)`, `(?-1)`, `(?R)`,
`(?&name)`, `(?P>name)`) or a condition other than an assertion (`(?(1)`,
`(?(R)`, `(?(DEFINE)`). Inside the shared regex a verb would act on the
other patterns' positions too, and the others would point at the wrong
group, as group numbers count from the start of the whole regex. In a regex such as `(?<![A-Za-z0-9_])ORD\d{4}|INV\d{4}`
the lookbehind applies to the first branch only, but in the shared regex
it would apply to both. The rest are scanned on
their own: those starting with a literal (`@`, `+`) or a keyword, which
the engine finds quickly, and those with flags or any of the above. A
pattern scanned on its own is skipped when the text lacks what its
`requires` field names: a literal, or a match for one of a list of
regexes.

A pattern in the shared pass must also begin every match with a digit or
a capital letter. The catalog's tests check this for every catalog
pattern that shares the pass.

## When the regex engine stops

The engine stops a match that takes too many steps and returns an error
(`:match_limit`). `Regex.scan/3` reads that error as no more matches, so
the rest of the text would go unscanned without a sign. The scanner reads
the error and raises `PiiCatalog.ScanError` instead. A pattern skipped
on a text that lacks its `requires` is still skipped; a `requires` regex
that stops with an error counts as present, so the pattern is scanned.

# `t`

```elixir
@opaque t()
```

# `find`

```elixir
@spec find(t(), binary()) :: [PiiCatalog.Match.t()]
```

The matches of `scan/2` that pass their pattern's validator, in the same
order. The validator sees the matched bytes alone. Raises
`PiiCatalog.ScanError` as `scan/2` does.

# `new`

```elixir
@spec new([PiiCatalog.Pattern.t()]) :: t()
```

Compiles a scanner for the given patterns, scanned in the order given.
A second pattern with a name already in the list is dropped. Raises
`ArgumentError` for a pattern whose `validator` is neither `nil` nor one
of `PiiCatalog.Validators.names/0`.

# `patterns`

```elixir
@spec patterns(t()) :: [PiiCatalog.Pattern.t()]
```

The patterns a scanner holds, in the order it reports them.

# `scan`

```elixir
@spec scan(t(), binary()) :: [PiiCatalog.Match.t()]
```

Every candidate match of every pattern in `text`, before validation.
Grouped by pattern in the scanner's order, and left to right within each.
Raises `PiiCatalog.ScanError` when the regex engine stops before the end
of the text.

# `shared?`

```elixir
@spec shared?(PiiCatalog.Pattern.t()) :: boolean()
```

Whether a pattern is scanned in the shared pass. Only catalog patterns
are: a pattern whose name is not in the catalog, or whose regex is not
the catalog's (same source and options), is scanned on its own. Of the
catalog patterns, those whose regex starts with `(?<![A-Za-z0-9_])`,
has no flags and no `|` outside parentheses, and has no `\K`,
backtracking verb, named group, backreference, subroutine call or
condition other than an assertion share the pass.

---

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