Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e2000f9
deprecate parse(); make parseSingle/Multiple/Stream the primary API
mmucklo Aug 26, 2026
64f1826
4.0: remove validateLocalPart() shim; make internal validators private
mmucklo Aug 26, 2026
a683a4c
4.0: ParseOptions readonly state fields; remove setters; deprecate ge…
mmucklo Aug 26, 2026
2b066a4
4.0: rename ParseContext accumulator fields snake_case -> camelCase
mmucklo Aug 26, 2026
a0f9bf7
4.0: make ParseContext snapshot + config readonly (immutable per parse)
mmucklo Aug 26, 2026
7896f0f
4.0: replace STATE_* int constants with a ParserState backed enum
mmucklo Aug 28, 2026
e4ced59
ci(psalm): suppress PossiblyUnusedMethod on the deprecated parse() shim
mmucklo Aug 28, 2026
4bf937f
docs: add v3.x -> v4.0 UPGRADE guide; reshuffle roadmap to lean 4.0
mmucklo Aug 28, 2026
d0c076c
docs(roadmap): add North Star (error identity vs presentation) + 4.1 …
mmucklo Aug 28, 2026
33d39ef
docs(roadmap): reword testspec item
mmucklo Aug 28, 2026
b5ae0f9
test: restore + increase coverage after the 4.0 API changes
mmucklo Aug 28, 2026
dbfbbbb
ci: make coverage deterministic (pin SEED); add two branch tests
mmucklo Aug 29, 2026
dde89f4
test+cov: cover reachable branches; mark two dead branches ignored
mmucklo Aug 29, 2026
602ff45
test: cover IP-literal global-range validation
mmucklo Aug 29, 2026
95f2aa3
ci: re-trigger full pipeline (fresh codecov + Scrutinizer report)
mmucklo Aug 30, 2026
5561927
review: deprecate redundant getters; fix stale comment; neutral cover…
mmucklo Aug 31, 2026
0b79142
test: pin parse() == parseSingle/parseMultiple()->toArray() (oracle)
mmucklo Aug 31, 2026
a698af0
review: LoggerAwareInterface; deprecate setOptions; suppress unused-m…
mmucklo Aug 31, 2026
c6e5d13
feat: ship Rector migration config for 3.x -> 4.0 call-site upgrades
mmucklo Sep 2, 2026
8c02b33
feat: emit runtime deprecation notices; add composer support metadata
mmucklo Sep 2, 2026
4c5e9a3
ci(scrutinizer): coverage only; drop the PHP analyzer
mmucklo Sep 14, 2026
3fa50a4
review: ownership-aware setter rewrite; document parse() override bre…
mmucklo Sep 14, 2026
8578713
docs: parse() decomposition shipped in 3.9.0; generic UPGRADE link
mmucklo Sep 15, 2026
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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ jobs:
run: composer install --prefer-dist --no-progress --no-suggest

- name: Run test suite with coverage
# Pin an arbitrary SEED so coverage is deterministic: PropertyTest fuzzes
# from a time-based seed by default, which makes the measured line count
# (and the Codecov delta) jitter run-to-run. The value is chosen for
# reproducibility only, not to maximize the number. The 8.1–8.6 test jobs
# stay unseeded so they keep fuzzing across the matrix.
env:
SEED: '1'
run: bin/phpunit --coverage-clover=coverage.xml --coverage-text

- name: Upload coverage to Codecov
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ infection-summary.log

# phpbench local storage (machine-specific wall-clock times; not portable)
.phpbench/
scratchpad/
2 changes: 2 additions & 0 deletions .php-cs-fixer.dist.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
$finder = PhpCsFixer\Finder::create()
->in(__DIR__ . '/src')
->in(__DIR__ . '/tests')
->in(__DIR__ . '/rector')
->exclude('fixtures')
->name('*.php')
->ignoreDotFiles(true)
->ignoreVCS(true);
Expand Down
18 changes: 8 additions & 10 deletions .scrutinizer.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
# .scrutinizer.yml
#
# Scrutinizer is used for coverage reporting only. Its bundled PHP analyzer
# (php-scrutinizer-run) does not model PHP 8.1 readonly properties or enum
# ->name/->value and flags correct code as bugs; static analysis is covered
# by PHPStan (level 8) and Psalm in GitHub Actions instead.

filter:
paths:
Expand All @@ -7,11 +12,6 @@ filter:
- 'tests/*'
- 'vendor/*'

checks:
php:
code_rating: true
duplication: true

build:
image: default-jammy
environment:
Expand All @@ -20,15 +20,13 @@ build:
override:
- composer install --no-interaction --no-scripts --ignore-platform-reqs
nodes:
analysis:
tests:
override:
- php-scrutinizer-run
tests:
tests:
override:
-
command: 'XDEBUG_MODE=coverage bin/phpunit --coverage-clover=.coverage'
# SEED pins the property-test fuzzer so the measured
# coverage is deterministic (matches the CI coverage job).
command: 'SEED=1 XDEBUG_MODE=coverage bin/phpunit --coverage-clover=.coverage'
coverage:
file: '.coverage'
format: 'clover'
20 changes: 11 additions & 9 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ over per-state handlers, backed by a per-parse context object.

| | |
|---|---|
| Entry point | `parse(string $emails, bool $multiple = true, string $encoding = 'UTF-8'): array` |
| Entry points | `parseSingle()` → `ParsedEmailAddress`, `parseMultiple()` → `ParseResult`, `parseStream()` → `Generator` (plus the deprecated array-returning `parse()`) |
| Core | All entry points funnel into the private `parseInternal(string $emails, bool $multiple, string $encoding): array` |
| Model | Character-by-character state machine, 12 states |
| `parse()` body | Setup + a `switch ($ctx->state)` dispatch loop (~193 lines) |
| Core body | Setup + a `switch ($ctx->state)` dispatch loop (~185 lines) |
| State handlers | 7 methods (one per switch arm) |
| Working state | `ParseContext` — one object per `parse()` call, ~24 accumulator fields |
| Working state | `ParseContext` — one object per parse, ~24 accumulator fields |
| Reentrancy | A fresh context per call; nothing parse-specific is stored on the `Parse` instance |

## The dispatch loop
Expand Down Expand Up @@ -106,16 +107,17 @@ last kind is cleared between addresses in a batch:
|---|---|---|
| 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) |
| Per-address accumulator + loop control | Cleared by `resetAddress()` | `state`, `subState`, `commentNestLevel`, `originalAddress`, `localPartParsed`, `domain`, `quoteTemp`, `comments[]`, `inAngleAddr`, ... (~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)).
The input snapshot and hoisted config are `public readonly` constructor-promoted
properties, so a state handler cannot mutate configuration mid-parse; only the
accumulator is writable. `state` and `subState` are typed as the `ParserState`
backed enum (`src/ParserState.php`), which replaced the former `Parse::STATE_*`
integer constants: the context can never hold an out-of-range state.

## Per-address reset

`resetAddress(int $state, int $subState)` is the single source of truth for
`resetAddress(ParserState $state, ParserState $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
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

### Added
- **Rector migration config** (`rector/upgrade-4.0.php`) that auto-fixes the mechanical 3.x → 4.0 call-site changes: `Parse::getInstance()` → `new Parse()`, `ParseOptions` pass-through getters → readonly-property reads, and the removed mutating setters → their `withX()` builders where the receiver is provably locally owned. Aliased receivers (parameters, `getOptions()` results, instances already passed to a parser) are left in place and annotated with a `TODO email-parse 4.0:` comment instead of being rewritten into a silently-diverging local reassignment. Opt-in (you run it and review the diff); see [UPGRADE.md](UPGRADE.md).

### Changed
- **`ParseOptions` state fields are now `public readonly`** — `bannedChars`, `separators`, `useWhitespaceAsSeparator`, `lengthLimits`, and `allowedWhitespace` are readable directly as properties (the pass-through `getX()` accessors still work but are deprecated, see below). Every `ParseOptions` property is now readonly; configure via the constructor or the `withX()` builders.
- **BREAKING: `Parse` now implements `Psr\Log\LoggerAwareInterface`, and `Parse::setLogger()` returns `void`** (was fluent, returned `Parse`). Standard PSR-3 logger injection; frameworks can auto-inject. If you chained on `setLogger()` (`$parser->setLogger($l)->…`), split it into two statements.

### Deprecated
- **Every deprecated method now emits a runtime `E_USER_DEPRECATED` notice** (via the new `symfony/deprecation-contracts` dependency's `trigger_deprecation()`), so you see the deprecation *when you call the old API* — not just as a docblock. Tools like `symfony/phpunit-bridge` aggregate these into a report pointing at the exact call-sites, and the shipped Rector config (see Added) can then auto-fix most of them.
- **`Parse::setOptions()`** — a parser's configuration should be immutable for the life of the instance; mutating it on a shared parser is a footgun. Pass options to the constructor (`new Parse($logger, $options)`) instead. Removed in 5.0.
- **`Parse::parse()`** (the polymorphic `$multiple`-boolean, array-returning API) is deprecated. Use `parseSingle()` / `parseMultiple()` for typed value objects, or `parseStream()` for large batches; call `->toArray()` on a result if you need the legacy array shape. `parse()` keeps working as a thin shim over the typed core and will be removed in 5.0.
- **`Parse::getInstance()`** — the default-options singleton is deprecated; use explicit instantiation (`new Parse($logger, $options)`), which also lets you pass custom options. Removed in 5.0.
- **`ParseOptions` pass-through getters** — `getBannedChars()`, `getSeparators()`, `getUseWhitespaceAsSeparator()`, `getLengthLimits()`, and `getAllowedWhitespace()` are deprecated; read the corresponding `public readonly` property instead (e.g. `$options->bannedChars`). They now duplicate the promoted properties. Removed in 5.0. The `getMax*Length()` helpers are **not** deprecated — they read into `$lengthLimits`.

### Removed
- **BREAKING: the deprecated `ParseOptions` mutating setters** — `setBannedChars`, `setSeparators`, `setUseWhitespaceAsSeparator`, `setLengthLimits`, `setMaxLocalPartLength`, `setMaxTotalLength`, `setMaxDomainLabelLength` (deprecated since v3.0) are removed. Configure via the constructor or the `withX()` builders; the state fields are now `public readonly`.
- **BREAKING: subclass overrides of `Parse::parse()` no longer affect `parseSingle()` / `parseMultiple()` / `parseStream()`.** The typed methods now call a private `parseInternal()` directly; the deprecated `parse()` is a shim beside them rather than the trunk they route through. Overriding the entry point was never a documented extension point; pre-process input before calling the parser, or wrap the typed result. See UPGRADE.md.
- **BREAKING: `Parse::validateLocalPart()`** — the `@deprecated` (3.9) `array`-based method is removed; local-part validation is now a `private`, `ParseContext`-based method. **`Parse::validateDomainName()` is now `private`.** Both took the parser's internal accumulator and were never a supported extension point — customize validation via `ParseOptions`. Any subclass that overrode them must move to `ParseOptions`-based configuration.

## [3.9.0]

Internal refactor of the `parse()` state machine into per-state handler methods backed by a new `ParseContext` object. Behavior-preserving and fully backward compatible — no public or protected signature changed and output is byte-identical. Adds the `ParseContext` type and deprecates `Parse::validateLocalPart()` (removed in 4.0). See [ARCHITECTURE.md](ARCHITECTURE.md).
Expand Down
40 changes: 19 additions & 21 deletions 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) · [ARCHITECTURE](ARCHITECTURE.md) · [ROADMAP](ROADMAP.md)
**Other docs:** [Cookbook (recipes)](docs/cookbook.md) · [CHANGELOG](CHANGELOG.md) · [UPGRADE guide](UPGRADE.md) · [DESIGN / RFC reference](DESIGN.md) · [ARCHITECTURE](ARCHITECTURE.md) · [ROADMAP](ROADMAP.md)

Installation:
-------------
Expand All @@ -32,28 +32,26 @@ Usage:
```php
use Email\Parse;

// Array-based API (v2.x-compatible)
$result = Parse::getInstance()->parse("a@aaa.com b@bbb.com");

// Typed value objects (v3.1+, recommended for new code)
$address = Parse::getInstance()->parseSingle('john@example.com');
// Typed value objects — parseSingle() / parseMultiple() / parseStream().
// (The legacy array-returning parse() is deprecated; see "Other Examples" below.)
$address = (new Parse())->parseSingle('john@example.com');
echo $address->localPart; // "john"
echo $address->domain; // "example.com"
if ($address->invalid) {
echo $address->invalidReasonCode->value;
}

$result = Parse::getInstance()->parseMultiple('a@a.com, b@b.com');
$result = (new Parse())->parseMultiple('a@a.com, b@b.com');
foreach ($result->emailAddresses as $addr) { /* ... */ }

// Streaming for large batches (v3.2+) — yields one address at a time.
foreach (Parse::getInstance()->parseStream($csvRows) as $addr) {
foreach ((new Parse())->parseStream($csvRows) as $addr) {
if ($addr->invalid) continue;
// ...
}

// Serialization (v3.3+)
$parsed = Parse::getInstance()->parseSingle('"J Doe" <j@example.com>');
$parsed = (new Parse())->parseSingle('"J Doe" <j@example.com>');
(string) $parsed; // "j@example.com" — Stringable returns simple_address
$parsed->canonical(); // 'J Doe <j@example.com>' — minimal RFC 5322 quoting
$parsed->toArray(); // legacy array shape, for mixed-API code
Expand All @@ -71,18 +69,18 @@ use Email\ParseOptions;
// Example 1: Use comma and semicolon as separators (default behavior includes whitespace)
$options = new ParseOptions([], [',', ';']);
$parser = new Parse(null, $options);
$result = $parser->parse("a@aaa.com; b@bbb.com, c@ccc.com");
$result = $parser->parseMultiple("a@aaa.com; b@bbb.com, c@ccc.com");

// Example 2: Disable whitespace as separator (only comma and semicolon work)
$options = new ParseOptions([], [',', ';'], false);
$parser = new Parse(null, $options);
$result = $parser->parse("a@aaa.com; b@bbb.com"); // Works - uses semicolon
$result = $parser->parse("a@aaa.com b@bbb.com"); // Won't split - whitespace not a separator
$result = $parser->parseMultiple("a@aaa.com; b@bbb.com"); // Works - uses semicolon
$result = $parser->parseMultiple("a@aaa.com b@bbb.com"); // Won't split - whitespace not a separator

// Example 3: Names with spaces always work regardless of whitespace separator setting
$options = new ParseOptions([], [',', ';'], false);
$parser = new Parse(null, $options);
$result = $parser->parse("John Doe <john@example.com>, Jane Smith <jane@example.com>");
$result = $parser->parseMultiple("John Doe <john@example.com>, Jane Smith <jane@example.com>");
// Returns 2 valid emails with names preserved
```

Expand Down Expand Up @@ -265,19 +263,19 @@ RFC 5322 allows comments in email addresses using parentheses. The parser automa
use Email\Parse;

// Single comment
$result = Parse::getInstance()->parseSingle('john@example.com (home address)');
$result = (new Parse())->parseSingle('john@example.com (home address)');
// $result->comments === ['home address']

// Multiple comments
$result = Parse::getInstance()->parseSingle('test(comment1)(comment2)@example.com');
$result = (new Parse())->parseSingle('test(comment1)(comment2)@example.com');
// $result->comments === ['comment1', 'comment2']

// Nested comments
$result = Parse::getInstance()->parseSingle('test@example.com (comment with (nested) parens)');
$result = (new Parse())->parseSingle('test@example.com (comment with (nested) parens)');
// $result->comments === ['comment with (nested) parens']

// No comments
$result = Parse::getInstance()->parseSingle('test@example.com');
$result = (new Parse())->parseSingle('test@example.com');
// $result->comments === []
```

Expand All @@ -293,7 +291,7 @@ See [UPGRADE.md](UPGRADE.md) for the complete list of breaking changes, deprecat

```php
// v2.x default (legacy behavior — still works in v3.0)
$parser = Parse::getInstance();
$parser = new Parse();

// v3.0 recommended default
$options = ParseOptions::rfc5322();
Expand Down Expand Up @@ -366,11 +364,11 @@ $result = $parser->parseSingle('müller@münchen.de');
Other Examples:
---------------

The following examples use the legacy array-returning `parse()` method to document its full output shape. New code should prefer `parseSingle()` / `parseMultiple()` (see Basic Usage) for typed return values; both APIs expose the same underlying fields.
New code uses `parseSingle()` / `parseMultiple()` (see Basic Usage) for typed value objects; call `->toArray()` on either result for the array form. The examples below illustrate the principal fields — see [`ParsedEmailAddress`](src/ParsedEmailAddress.php) for the complete, canonical set. (The old `parse()` method is deprecated and will be removed in 5.0.)

```php
$email = '"J Doe" <johndoe@xyz.com>';
$result = Email\Parse::getInstance()->parse($email, false);
$result = (new Email\Parse())->parseSingle($email)->toArray();

$result == array(
'address' => '"J Doe" <johndoe@xyz.com>',
Expand All @@ -389,7 +387,7 @@ The following examples use the legacy array-returning `parse()` method to docume
'comments' => []);

$emails = 'testing@[8.8.8.8] testing@xyz.com, "test.2"@xyz.com (comment)';
$result = Email\Parse::getInstance()->parse($emails);
$result = (new Email\Parse())->parseMultiple($emails)->toArray();
$result == array(
'success' => true,
'reason' => null,
Expand Down
Loading
Loading