PiiCatalog.Scanner (pii_catalog v0.1.0)

Copy Markdown View Source

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.

Summary

Functions

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.

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.

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

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.

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.

Types

t()

@opaque t()

Functions

find(scanner, text)

@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(patterns)

@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(scanner)

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

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

scan(scanner, text)

@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?(pattern)

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