Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Parser Architecture

How `Email\Parse` turns a string of addresses into parsed results. This is the
*implementation* companion to [`DESIGN.md`](DESIGN.md), which covers the RFC
*semantics* (what counts as valid and why). Here the subject is the shape of the
code: a character-by-character state machine, decomposed into a dispatch loop
over per-state handlers, backed by a per-parse context object.

## At a glance

| | |
|---|---|
| Entry point | `parse(string $emails, bool $multiple = true, string $encoding = 'UTF-8'): array` |
| Model | Character-by-character state machine, 12 states |
| `parse()` body | Setup + a `switch ($ctx->state)` dispatch loop (~193 lines) |
| State handlers | 7 methods (one per switch arm) |
| Working state | `ParseContext` — one object per `parse()` call, ~24 accumulator fields |
| Reentrancy | A fresh context per call; nothing parse-specific is stored on the `Parse` instance |

## The dispatch loop

`parse()` reads the input once, left to right. Each iteration reads one
character, dispatches on the current state to a handler that mutates the
context, and — when an address boundary is reached — commits the address and
resets for the next one.

```mermaid
flowchart TD
A["for i in 0..len<br/>read curChar, keep prevChar"] --> B{"switch ctx.state"}
B --> C["state handler<br/>mutates ctx"]
C --> D{"ctx.state == END_ADDRESS<br/>and got characters?"}
D -- "yes" --> E["addAddress()<br/>build output row"]
E --> F["ctx.resetAddress(TRIM, START)"]
F --> A
D -- "no" --> A
```

The `TRIM → ADDRESS` transition is a genuine `switch` fall-through: a plain
character seen in the trim state *is* the first character of the address, so
control drops straight from the `TRIM` arm into the `ADDRESS` arm without
re-reading. That is why `handleStateTrim()` returns a `bool` — `true` tells the
loop to fall through.

## The states

`ADDRESS` is the hub. It runs the addr-spec walk via an inner `subState` machine
(`LOCAL_PART → DOMAIN → AFTER_DOMAIN`, plus `NAME` for display names). From the
hub the parser makes bounded *excursions* into quoted strings, nested comments,
address literals, and obsolete source routes; each returns to `ADDRESS`. A
separator or end-of-input drops to `END_ADDRESS`, which commits the address and
loops back to `TRIM`. Malformed input diverts to `SKIP_AHEAD`, which
resynchronizes at the next separator.

```mermaid
stateDiagram-v2
[*] --> TRIM
TRIM --> ADDRESS: plain char, fall-through

ADDRESS --> QUOTE: double-quote
QUOTE --> ADDRESS: return
ADDRESS --> COMMENT: open-paren
COMMENT --> ADDRESS: return
ADDRESS --> SQUARE_BRACKET: open-bracket
SQUARE_BRACKET --> ADDRESS: return
ADDRESS --> OBS_ROUTE: obs-route
OBS_ROUTE --> ADDRESS: return

ADDRESS --> SKIP_AHEAD: on invalid
SKIP_AHEAD --> END_ADDRESS: next separator

ADDRESS --> END_ADDRESS: separator / EOF
END_ADDRESS --> TRIM: next address, resetAddress
END_ADDRESS --> [*]: end of input
```

Each switch arm is a method, so a state's logic is isolated and independently
readable:

| State | Handler | Responsibility |
|---|---|---|
| `SKIP_AHEAD` | `handleStateSkipAhead` | Error recovery — consume until the next separator |
| `TRIM` | `handleStateTrim` | Skip leading separators/whitespace; signal fall-through |
| `ADDRESS` | `handleStateAddress` | The addr-spec walk (local-part `@` domain, display name) |
| `SQUARE_BRACKET` | `handleStateSquareBracket` | `[...]` domain / address literal |
| `OBS_ROUTE` | `handleStateObsRoute` | Obsolete `@a,@b:addr` source route |
| `QUOTE` | `handleStateQuote` | Quoted-string local-part or display name |
| `COMMENT` | `handleStateComment` | Nested `( ... )` comments |

`handleStateAddress` further delegates the per-character work to
`handleAddressWhitespace` (CFWS/folding), `handleAddressAt` (the `@` boundary),
and `handleAddressNonAtext` (punctuation and specials). `addAddress()` builds the
public output array; its shape is independent of the context object.

## ParseContext

All of the loop's working state lives on one object, `ParseContext`. A fresh
instance is created for every `parse()` call and is never stored on the `Parse`
instance. That is the whole reentrancy story: a caller-supplied
`localPartNormalizer` closure may call back into `parse()` mid-parse, and the
inner call gets its own context instead of clobbering the outer one's.

The object holds three kinds of field. The distinction matters because only the
last kind is cleared between addresses in a batch:

| Group | Lifetime | Fields (representative) |
|---|---|---|
| Input snapshot | Set once per parse, never reset | `chars[]`, `len`, `emails`, `multiple` |
| Hoisted config | Set once per parse, never reset | `separators`, `bannedChars`, `allowedWhitespace`, `useWhitespaceAsSeparator` |
| Per-address accumulator + loop control | Cleared by `resetAddress()` | `state`, `subState`, `commentNestLevel`, `original_address`, `local_part_parsed`, `domain`, `quote_temp`, `comments[]`, `in_angle_addr`, ... (~24 total) |

The accumulator field names deliberately mirror the historical loop-local
variable names so they thread through the validation helpers unchanged; the
rename to the codebase's `camelCase` convention is a tracked follow-up (see
[`ROADMAP.md`](ROADMAP.md)).

## Per-address reset

`resetAddress(int $state, int $subState)` is the single source of truth for
clearing per-address state between addresses in a batch. It zeroes the
accumulator *and* the three loop-control fields — `state`, `subState`, and
`commentNestLevel`. Both call sites use it: the initial setup before the loop and
the reset after each committed address.

Consolidating this matters for a subtle reason. `commentNestLevel` previously had
no explicit reset at all — it stayed correct only because entering a comment with
a leading `(` reassigns the level to `1`. Any future per-address field added to
the wrong place would have silently leaked into the next address in a batch.
Routing all per-address state through one method removes that trap: a new field
has exactly one place to be cleared.

## Invariants

- **Behavior-preserving.** The decomposition changed structure only; parsing
logic, conditions, and ordering are unchanged, and the output arrays are
byte-identical. Gated by the full test suite, PHPStan level 8, and Psalm.
- **Reentrant.** No per-parse state on the `Parse` instance; a normalizer
callback may re-enter `parse()` safely.
- **No performance regression.** Hard constraint on the refactor; `chars`/`len`
are kept as loop locals (not only context properties) for hot-loop locality.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

### Changed
- **Internal: `Parse::parse()` decomposed** into a per-state handler dispatch loop backed by a new `ParseContext` accumulator object. Pure structural refactor — no change to parsing logic, conditions, ordering, error codes, or output shape; the address arrays and `ParsedEmailAddress` objects are byte-identical, and **no public or protected method signature changed** (fully backward compatible). A fresh `ParseContext` is created per call and never stored on the parser, so `parse()` is reentrant across a `localPartNormalizer` callback. See [ARCHITECTURE.md](ARCHITECTURE.md).

### Deprecated
- **`protected Parse::validateLocalPart(array $emailAddress)`** — deprecated, removed in 4.0. It keeps its original `array` signature and remains a live extension point (a subclass override is still invoked), so existing subclasses keep working; going forward, customize validation through `ParseOptions` instead. The new `ParseContext` accumulator is `@internal` — its field shape is not a stable API.

## [3.8.0]

Adds opt-in homoglyph / confusable-domain detection. Additive and off by default — no behavior change unless you enable it.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Email\Parse is a batch email address parser with configurable RFC compliance lev

It parses a list of 1 to n email addresses separated by comma and whitespace by default, with configurable separators (e.g. semicolon).

**Other docs:** [Cookbook (recipes)](docs/cookbook.md) · [CHANGELOG](CHANGELOG.md) · [UPGRADE guide (v2.x → v3.0)](UPGRADE.md) · [DESIGN / RFC reference](DESIGN.md) · [ROADMAP](ROADMAP.md)
**Other docs:** [Cookbook (recipes)](docs/cookbook.md) · [CHANGELOG](CHANGELOG.md) · [UPGRADE guide (v2.x → v3.0)](UPGRADE.md) · [DESIGN / RFC reference](DESIGN.md) · [ARCHITECTURE](ARCHITECTURE.md) · [ROADMAP](ROADMAP.md)

Installation:
-------------
Expand Down
Loading
Loading