From e2000f9ee6eb46627125001bbdac875f79113b83 Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Tue, 25 Aug 2026 23:44:45 -0700 Subject: [PATCH 01/23] deprecate parse(); make parseSingle/Multiple/Stream the primary API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4.0 groundwork. The polymorphic array-returning parse() is deprecated (removed in 5.0); the typed methods are now the entry points. - Extract the state-machine core into a private parseInternal(); parse() becomes a thin @deprecated shim over it, and parseSingle/parseMultiple/ parseStream call parseInternal directly (no longer routed through the deprecated method). parse() output is byte-identical. - Rewrite the testspec runner onto parseMultiple()->toArray() / parseSingle()->toArray() — validates the typed path and drops the suite's dependency on parse(). 236 assertions unchanged. - Docs: README (Basic Usage, ParseOptions examples, Other Examples), cookbook, UPGRADE, and ARCHITECTURE now use the single-purpose methods; parse() is presented only as the deprecated legacy shim. - Roadmap: parse() deprecation recorded in the ledger; removal moved to a new v5.0 section. CHANGELOG [Unreleased] Deprecated entry added. 110 tests / 7214 assertions, PHPStan L8, Psalm, CS all green. --- .gitignore | 1 + ARCHITECTURE.md | 7 ++++--- CHANGELOG.md | 3 +++ README.md | 20 +++++++++----------- ROADMAP.md | 7 ++++++- UPGRADE.md | 6 +++--- docs/cookbook.md | 6 +++--- src/Parse.php | 40 ++++++++++++++++++++++++++++------------ tests/ParseTest.php | 6 +++++- 9 files changed, 62 insertions(+), 34 deletions(-) diff --git a/.gitignore b/.gitignore index 4e11f6b..ee01122 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ infection-summary.log # phpbench local storage (machine-specific wall-clock times; not portable) .phpbench/ +scratchpad/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ab563ad..010e262 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index d35fbcf..12dd133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Deprecated +- **`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. + ## [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). diff --git a/README.md b/README.md index 9035163..283af22 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,8 @@ 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) +// Typed value objects — parseSingle() / parseMultiple() / parseStream(). +// (The legacy array-returning parse() is deprecated; see "Other Examples" below.) $address = Parse::getInstance()->parseSingle('john@example.com'); echo $address->localPart; // "john" echo $address->domain; // "example.com" @@ -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 , Jane Smith "); +$result = $parser->parseMultiple("John Doe , Jane Smith "); // Returns 2 valid emails with names preserved ``` @@ -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" '; - $result = Email\Parse::getInstance()->parse($email, false); + $result = Email\Parse::getInstance()->parseSingle($email)->toArray(); $result == array( 'address' => '"J Doe" ', @@ -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 = Email\Parse::getInstance()->parseMultiple($emails)->toArray(); $result == array( 'success' => true, 'reason' => null, diff --git a/ROADMAP.md b/ROADMAP.md index 21af298..87a5331 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -32,6 +32,7 @@ below as a record; planned work follows. - **v3.0:** `LengthLimits` moved to readonly constructor promotion (getters/setters removed — see [UPGRADE.md](UPGRADE.md)). The `ParseOptions` setters (`setBannedChars`, `setSeparators`, `setUseWhitespaceAsSeparator`, `setLengthLimits`, `setMaxLocalPartLength`, `setMaxTotalLength`, `setMaxDomainLabelLength`) are marked `@deprecated` and still functional; removal is targeted for v4.0. - **v3.9:** `protected Parse::validateLocalPart(array $emailAddress)` marked `@deprecated`. Still functional and still a live extension point (subclass overrides are invoked), but customizing validation via `ParseOptions` is the supported path; removal is targeted for v4.0 (see Planned below). +- **v4.0:** `Parse::parse()` (the polymorphic array API) marked `@deprecated` — kept as a working shim over the typed methods; removal targeted for v5.0. - `RfcMode` never shipped (existed only on a feature branch). ### Community & documentation @@ -75,7 +76,7 @@ Continuous work, not tied to a specific release. **API cleanup:** - [ ] Remove the `@deprecated` `ParseOptions` setters (deprecated in v3.0). - [ ] Promote the `ParseOptions` state fields (`bannedChars`, `separators`, `useWhitespaceAsSeparator`, `lengthLimits`) to public `readonly` via constructor promotion with named arguments. -- [ ] Remove the polymorphic `parse()` in favor of `parseSingle()` / `parseMultiple()` with typed returns — drops the `$multiple` boolean parameter. +- [x] **Deprecate** the polymorphic `parse()` (the `$multiple`-boolean array API) in favor of `parseSingle()` / `parseMultiple()` / `parseStream()`. Kept as a thin `@deprecated` shim over the private `parseInternal()` core, so it still works; **removal deferred to 5.0** (see below). - [ ] Deprecate or remove the `getInstance()` singleton (recommend explicit instantiation). - [ ] Remove the deprecated `Parse::validateLocalPart(array)` extension point (deprecated when the `parse()` decomposition landed) and fold local-part validation into a `private`, `ParseContext`-based method; likewise make `validateDomainName()` `private`. They take the parser's internal accumulator and were never a supported extension point — validation is customized through `ParseOptions`. @@ -84,6 +85,10 @@ Continuous work, not tied to a specific release. - [ ] Group syntax (RFC 6854: `Group Name: addr1, addr2;`) — introduces a new output-container shape for grouped results. - [ ] Confusable-against-a-target-list matching — compare the domain's Unicode skeleton against a caller-supplied brand/skeleton set (`Spoofchecker::areConfusable()`), following on from the v3.8 single-string check. Deferred until the caller-provided target list is designed. +### v5.0 — planned + +- [ ] Remove the deprecated `parse()` method (deprecated in 4.0). `parseSingle()` / `parseMultiple()` / `parseStream()` are the entry points; the private `parseInternal()` core stays. + ### Backlog (unversioned) - [ ] **`parse()` refactor & modernization follow-ups** (from review; non-blocking, each behavior-preserving and test-gated): diff --git a/UPGRADE.md b/UPGRADE.md index 55f89c7..0de5c9d 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -167,9 +167,9 @@ Recommended: match on the `invalid` boolean, not error text. A typed `ParseError Each parsed address now includes a `domain_ascii` field. It is `null` unless `ParseOptions::$includeDomainAscii` is `true` (the default in the `rfc6531()` preset only). Existing code that reads other fields is unaffected. ```php -$result = $parser->parse('user@bücher.de', false); -$result['domain']; // 'bücher.de' -$result['domain_ascii']; // 'xn--bcher-kva.de' (when includeDomainAscii=true), else null +$addr = $parser->parseSingle('user@bücher.de'); +$addr->domain; // 'bücher.de' +$addr->domainAscii; // 'xn--bcher-kva.de' (when includeDomainAscii=true), else null ``` #### New factory presets on `ParseOptions` diff --git a/docs/cookbook.md b/docs/cookbook.md index f99a0b3..41707a8 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -205,12 +205,12 @@ foreach ($addresses as $raw) { Prefer explicit instantiation; `Parse::getInstance()` (a singleton with default options) exists for convenience and backward compatibility. -## The legacy array API +## The array shape -`parse()` returns the original array shape — `parse($input, multiple: false)` for one address, `true` for many. Typed objects expose the same data via `->toArray()`. +Need the original array shape rather than a value object? Call `->toArray()` on any `parseSingle()` / `parseMultiple()` result — it exposes the same fields. (The old array-returning `parse()` method is deprecated and will be removed in 5.0.) ```php -$arr = (new Parse())->parse('john@example.com', false); +$arr = (new Parse())->parseSingle('john@example.com')->toArray(); $arr['local_part']; // 'john' $arr['domain']; // 'example.com' $arr['invalid']; // false diff --git a/src/Parse.php b/src/Parse.php index e205782..58a4a33 100644 --- a/src/Parse.php +++ b/src/Parse.php @@ -160,6 +160,12 @@ private function validateIpGlobalRange(string $ip, int $ipType): bool /** * Parses a list of 1 to n email addresses separated by space or comma. * + * @deprecated 4.0 Use {@see parseSingle()} for one address or + * {@see parseMultiple()} for a list — both return typed value + * objects (or {@see parseStream()} for large batches). This + * polymorphic array-returning method is retained for backward + * compatibility and will be removed in 5.0. + * * Compliance level is controlled by the ParseOptions passed to the constructor: * - ParseOptions::rfc5321() — RFC 5321 Mailbox (strict ASCII, SMTP-compatible) * - ParseOptions::rfc6531() — RFC 6531/6532 (full UTF-8, NFC normalization) @@ -215,28 +221,30 @@ private function validateIpGlobalRange(string $ip, int $ipType): bool * 'invalid_reason_code' => ParseErrorCode|null, 'comments' => array) * endif; */ + public function parse(string $emails, bool $multiple = true, string $encoding = 'UTF-8'): array + { + return $this->parseInternal($emails, $multiple, $encoding); + } + /** - * Parse a single email address and return a typed value object. - * - * Recommended over {@see parse()} when you want IDE autocomplete and - * static-analysis friendly access to the parsed fields. + * Parse a single email address and return a typed {@see ParsedEmailAddress} + * value object with IDE autocomplete and static-analysis-friendly fields. */ public function parseSingle(string $email, string $encoding = 'UTF-8'): ParsedEmailAddress { - return ParsedEmailAddress::fromArray($this->parse($email, false, $encoding)); + return ParsedEmailAddress::fromArray($this->parseInternal($email, false, $encoding)); } /** - * Parse a list of email addresses and return a typed result. + * Parse a list of email addresses and return a typed {@see ParseResult}. * - * Recommended over {@see parse()} in multi-address mode for the same reasons as - * {@see parseSingle()}. Separator handling and per-address rules are configured - * via {@see ParseOptions}. + * Separator handling and per-address rules are configured via + * {@see ParseOptions}. */ public function parseMultiple(string $emails, string $encoding = 'UTF-8'): ParseResult { /** @var array{success: bool, reason: ?string, email_addresses: array>} $raw */ - $raw = $this->parse($emails, true, $encoding); + $raw = $this->parseInternal($emails, true, $encoding); return ParseResult::fromArray($raw); } @@ -263,14 +271,22 @@ public function parseMultiple(string $emails, string $encoding = 'UTF-8'): Parse public function parseStream(iterable $input, string $encoding = 'UTF-8'): \Generator { foreach ($input as $emails) { - $result = $this->parse((string) $emails, true, $encoding); + $result = $this->parseInternal((string) $emails, true, $encoding); foreach ($result['email_addresses'] as $address) { yield ParsedEmailAddress::fromArray($address); } } } - public function parse(string $emails, bool $multiple = true, string $encoding = 'UTF-8'): array + /** + * Core parser shared by parseSingle()/parseMultiple()/parseStream() and the + * deprecated {@see parse()} shim. Returns the raw array documented on parse(): + * the multi-address envelope when $multiple is true, or a single-address hash + * otherwise. + * + * @return array + */ + private function parseInternal(string $emails, bool $multiple, string $encoding): array { $emailAddresses = []; diff --git a/tests/ParseTest.php b/tests/ParseTest.php index 0aa3186..185c399 100644 --- a/tests/ParseTest.php +++ b/tests/ParseTest.php @@ -118,7 +118,11 @@ public function testParseEmailAddresses() $options = $this->buildOptions($test); $parser = new Parse(null, $options); - $actual = $parser->parse($emails, $multiple); + // Drive the typed API (not the deprecated parse()); toArray() reproduces + // the legacy array shape the fixtures assert against. + $actual = $multiple + ? $parser->parseMultiple($emails)->toArray() + : $parser->parseSingle($emails)->toArray(); // YAML tests written before ParseErrorCode landed omit `invalid_reason_code`. // Reconcile: where the expected entry doesn't mention the key, strip it from From 64f182661663adc8ff4139e72b8708e64ff9c5e8 Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Tue, 25 Aug 2026 23:47:41 -0700 Subject: [PATCH 02/23] 4.0: remove validateLocalPart() shim; make internal validators private Completes the 3.9 deprecation: local-part validation is folded back into a private ParseContext-based validateLocalPart() (dropping the array-shaped BC shim and its psalm-suppress), and validateDomainName() is now private too. Both took the parser's internal accumulator and were never a supported extension point; validation is customized through ParseOptions. BREAKING for any subclass that overrode them. Removed the now-moot BC-override test; CHANGELOG Removed entry and roadmap/ledger updated. 109 tests / 7187 assertions, PHPStan L8, Psalm, CS all green. --- CHANGELOG.md | 3 +++ ROADMAP.md | 4 ++-- src/Parse.php | 25 ++++++------------------- tests/ParseTest.php | 29 ----------------------------- 4 files changed, 11 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12dd133..fbb4b05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Deprecated - **`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. +### Removed +- **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). diff --git a/ROADMAP.md b/ROADMAP.md index 87a5331..774a230 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -31,7 +31,7 @@ below as a record; planned work follows. ### Deprecations - **v3.0:** `LengthLimits` moved to readonly constructor promotion (getters/setters removed — see [UPGRADE.md](UPGRADE.md)). The `ParseOptions` setters (`setBannedChars`, `setSeparators`, `setUseWhitespaceAsSeparator`, `setLengthLimits`, `setMaxLocalPartLength`, `setMaxTotalLength`, `setMaxDomainLabelLength`) are marked `@deprecated` and still functional; removal is targeted for v4.0. -- **v3.9:** `protected Parse::validateLocalPart(array $emailAddress)` marked `@deprecated`. Still functional and still a live extension point (subclass overrides are invoked), but customizing validation via `ParseOptions` is the supported path; removal is targeted for v4.0 (see Planned below). +- **v3.9 → removed v4.0:** `protected Parse::validateLocalPart(array $emailAddress)` was deprecated in 3.9 and is removed in 4.0 (now a `private` `ParseContext`-based method). Validation is customized via `ParseOptions`. - **v4.0:** `Parse::parse()` (the polymorphic array API) marked `@deprecated` — kept as a working shim over the typed methods; removal targeted for v5.0. - `RfcMode` never shipped (existed only on a feature branch). @@ -78,7 +78,7 @@ Continuous work, not tied to a specific release. - [ ] Promote the `ParseOptions` state fields (`bannedChars`, `separators`, `useWhitespaceAsSeparator`, `lengthLimits`) to public `readonly` via constructor promotion with named arguments. - [x] **Deprecate** the polymorphic `parse()` (the `$multiple`-boolean array API) in favor of `parseSingle()` / `parseMultiple()` / `parseStream()`. Kept as a thin `@deprecated` shim over the private `parseInternal()` core, so it still works; **removal deferred to 5.0** (see below). - [ ] Deprecate or remove the `getInstance()` singleton (recommend explicit instantiation). -- [ ] Remove the deprecated `Parse::validateLocalPart(array)` extension point (deprecated when the `parse()` decomposition landed) and fold local-part validation into a `private`, `ParseContext`-based method; likewise make `validateDomainName()` `private`. They take the parser's internal accumulator and were never a supported extension point — validation is customized through `ParseOptions`. +- [x] Removed the deprecated `Parse::validateLocalPart(array)` extension point (deprecated in 3.9) — local-part validation is now a `private`, `ParseContext`-based method, and `validateDomainName()` is `private` too. They took the parser's internal accumulator and were never a supported extension point — validation is customized through `ParseOptions`. **New capabilities (breaking or late-binding):** - [ ] DNS/MX validation via a `DnsValidator` callback interface — breaking because the `Parse` constructor grows, and synchronous lookups change performance characteristics. diff --git a/src/Parse.php b/src/Parse.php index 58a4a33..6e449e0 100644 --- a/src/Parse.php +++ b/src/Parse.php @@ -1317,15 +1317,9 @@ private function addAddress( $ctx->invalid_reason_code = Err::InvalidDisplayNamePhrase; } - // Unified local-part validation. Dispatched through validateLocalPart(), - // a deprecated but backward-compatible extension point (removed in 4.0), - // so it still receives the legacy accumulator-array shape it always took. + // Unified local-part validation. if (!$ctx->invalid) { - /** @psalm-suppress DeprecatedMethod Intentional BC hook so subclass overrides still fire; see validateLocalPart(). */ - $result = $this->validateLocalPart([ - 'local_part_parsed' => $ctx->local_part_parsed, - 'local_part_quoted' => $ctx->local_part_quoted, - ]); + $result = $this->validateLocalPart($ctx); if (!$result['valid']) { $ctx->invalid = true; $ctx->invalid_reason = $result['reason']; @@ -1451,20 +1445,13 @@ private function isDomainConfusable(string $domain): bool /** * Unified local-part validation based on ParseOptions rule properties. * - * @deprecated 3.9.0 Not a supported extension point going forward — customize - * validation through ParseOptions, not by overriding this. Kept - * with its original array signature for backward compatibility - * and removed in 4.0. Receives the accumulator keys it reads: - * `local_part_parsed` (string) and `local_part_quoted` (bool). - * - * @param array{local_part_parsed: string, local_part_quoted: bool} $emailAddress * @return array{valid: bool, reason: ?string, code: ?ParseErrorCode, normalized: ?string} */ - protected function validateLocalPart(array $emailAddress): array + private function validateLocalPart(ParseContext $ctx): array { $opts = $this->options; - $localPart = $emailAddress['local_part_parsed']; - $quoted = $emailAddress['local_part_quoted']; + $localPart = $ctx->local_part_parsed; + $quoted = $ctx->local_part_quoted; // RFC 6531 §3.3 / RFC 6532 §3.2: gate UTF-8 presence before other checks // (allowUtf8LocalPart is false in rfc5321() and rfc5322() presets) @@ -1660,7 +1647,7 @@ protected function normalizeDomainAscii(string $domain): ?string * * @return array{valid: bool, reason?: string, code?: ParseErrorCode} */ - protected function validateDomainName(string $domain): array + private function validateDomainName(string $domain): array { // RFC 5321 §4.5.3.1.2: total domain length limit is in octets if (strlen($domain) > 255) { diff --git a/tests/ParseTest.php b/tests/ParseTest.php index 185c399..a9d231b 100644 --- a/tests/ParseTest.php +++ b/tests/ParseTest.php @@ -1730,33 +1730,4 @@ public function testParserIsReentrantAcrossLocalPartNormalizer(): void $this->assertSame('inner.user', $innerResult->localPart); $this->assertSame('nested.example.org', $innerResult->domain); } - - /** - * Backward compatibility: validateLocalPart() keeps its original array - * signature as a deprecated extension point (removed in 4.0). A subclass - * override must still be invoked and able to change the outcome — the parser - * dispatches through $this->validateLocalPart(), not a renamed internal. - */ - public function testDeprecatedValidateLocalPartOverrideStillTakesEffect(): void - { - $parser = new class () extends Parse { - protected function validateLocalPart(array $emailAddress): array - { - if ('blocked' === $emailAddress['local_part_parsed']) { - return ['valid' => false, 'reason' => 'blocked local part', 'code' => null, 'normalized' => null]; - } - - return parent::validateLocalPart($emailAddress); - } - }; - - // Un-blocked address flows through parent::validateLocalPart() unchanged. - $ok = $parser->parseSingle('allowed@example.com'); - $this->assertFalse($ok->invalid); - - // The override fires and rejects an otherwise-valid address. - $blocked = $parser->parseSingle('blocked@example.com'); - $this->assertTrue($blocked->invalid, 'subclass validateLocalPart() override was not honored'); - $this->assertSame('blocked local part', $blocked->invalidReason); - } } From a683a4cc9efab42923f4cef984aaa2552cd5e797 Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Wed, 26 Aug 2026 00:04:56 -0700 Subject: [PATCH 03/23] 4.0: ParseOptions readonly state fields; remove setters; deprecate getInstance - Promote the 5 ParseOptions state fields (bannedChars, separators, useWhitespaceAsSeparator, lengthLimits, allowedWhitespace) to public readonly, assigned once in the constructor. The getX() accessors remain. - Remove the 7 @deprecated mutating setters (deprecated since v3.0). No call sites; configure via the constructor or withX() builders. - Deprecate Parse::getInstance() (removed in 5.0). The static singleton carries process-global state and is pinned to the LEGACY preset, so it silently applies permissive defaults; modern PHP uses explicit instantiation / DI. Docs and tests switched to new Parse(). - Replace the deprecated-setters test with one covering the readonly properties + withX() builders. Prune 7 now-stale psalm baseline entries. - CHANGELOG/ROADMAP/ledger updated. 109 tests / 7165 assertions, PHPStan L8, Psalm, CS all green. --- CHANGELOG.md | 5 ++ README.md | 22 +++---- ROADMAP.md | 11 ++-- docs/cookbook.md | 2 +- psalm-baseline.xml | 7 --- src/Parse.php | 6 +- src/ParseOptions.php | 106 ++++++++-------------------------- tests/ParseTest.php | 127 +++++++++++++++++++---------------------- tests/PropertyTest.php | 10 ++-- 9 files changed, 118 insertions(+), 178 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbb4b05..8375901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Changed +- **`ParseOptions` state fields are now `public readonly`** — `bannedChars`, `separators`, `useWhitespaceAsSeparator`, `lengthLimits`, and `allowedWhitespace` are readable directly as properties (the existing `getX()` accessors remain). Every `ParseOptions` property is now readonly; configure via the constructor or the `withX()` builders. + ### Deprecated - **`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. ### 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: `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] diff --git a/README.md b/README.md index 283af22..b537a2a 100644 --- a/README.md +++ b/README.md @@ -34,24 +34,24 @@ use Email\Parse; // Typed value objects — parseSingle() / parseMultiple() / parseStream(). // (The legacy array-returning parse() is deprecated; see "Other Examples" below.) -$address = Parse::getInstance()->parseSingle('john@example.com'); +$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" '); +$parsed = (new Parse())->parseSingle('"J Doe" '); (string) $parsed; // "j@example.com" — Stringable returns simple_address $parsed->canonical(); // 'J Doe ' — minimal RFC 5322 quoting $parsed->toArray(); // legacy array shape, for mixed-API code @@ -263,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 === [] ``` @@ -291,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(); @@ -368,7 +368,7 @@ New code uses `parseSingle()` / `parseMultiple()` (see Basic Usage) for typed va ```php $email = '"J Doe" '; - $result = Email\Parse::getInstance()->parseSingle($email)->toArray(); + $result = (new Email\Parse())->parseSingle($email)->toArray(); $result == array( 'address' => '"J Doe" ', @@ -387,7 +387,7 @@ New code uses `parseSingle()` / `parseMultiple()` (see Basic Usage) for typed va 'comments' => []); $emails = 'testing@[8.8.8.8] testing@xyz.com, "test.2"@xyz.com (comment)'; - $result = Email\Parse::getInstance()->parseMultiple($emails)->toArray(); + $result = (new Email\Parse())->parseMultiple($emails)->toArray(); $result == array( 'success' => true, 'reason' => null, diff --git a/ROADMAP.md b/ROADMAP.md index 774a230..f0de0c4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,9 +30,11 @@ below as a record; planned work follows. ### Deprecations -- **v3.0:** `LengthLimits` moved to readonly constructor promotion (getters/setters removed — see [UPGRADE.md](UPGRADE.md)). The `ParseOptions` setters (`setBannedChars`, `setSeparators`, `setUseWhitespaceAsSeparator`, `setLengthLimits`, `setMaxLocalPartLength`, `setMaxTotalLength`, `setMaxDomainLabelLength`) are marked `@deprecated` and still functional; removal is targeted for v4.0. +- **v3.0 → removed v4.0:** the `ParseOptions` mutating setters (`setBannedChars`, `setSeparators`, `setUseWhitespaceAsSeparator`, `setLengthLimits`, `setMaxLocalPartLength`, `setMaxTotalLength`, `setMaxDomainLabelLength`) were deprecated in v3.0 and are removed in v4.0. The state fields are `public readonly`; configure via the constructor or `withX()` builders. +- **v3.0:** `LengthLimits` moved to readonly constructor promotion (getters/setters removed — see [UPGRADE.md](UPGRADE.md)). - **v3.9 → removed v4.0:** `protected Parse::validateLocalPart(array $emailAddress)` was deprecated in 3.9 and is removed in 4.0 (now a `private` `ParseContext`-based method). Validation is customized via `ParseOptions`. - **v4.0:** `Parse::parse()` (the polymorphic array API) marked `@deprecated` — kept as a working shim over the typed methods; removal targeted for v5.0. +- **v4.0:** `Parse::getInstance()` (default-options singleton) marked `@deprecated` — use `new Parse($logger, $options)`; removal targeted for v5.0. - `RfcMode` never shipped (existed only on a feature branch). ### Community & documentation @@ -74,10 +76,10 @@ Continuous work, not tied to a specific release. ### v4.0 — Breaking modernization **API cleanup:** -- [ ] Remove the `@deprecated` `ParseOptions` setters (deprecated in v3.0). -- [ ] Promote the `ParseOptions` state fields (`bannedChars`, `separators`, `useWhitespaceAsSeparator`, `lengthLimits`) to public `readonly` via constructor promotion with named arguments. +- [x] Removed the `@deprecated` `ParseOptions` setters (deprecated in v3.0). +- [x] Promoted the `ParseOptions` state fields (`bannedChars`, `separators`, `useWhitespaceAsSeparator`, `lengthLimits`, `allowedWhitespace`) to `public readonly`. Configured via the constructor or `withX()` builders; the `getX()` accessors remain. - [x] **Deprecate** the polymorphic `parse()` (the `$multiple`-boolean array API) in favor of `parseSingle()` / `parseMultiple()` / `parseStream()`. Kept as a thin `@deprecated` shim over the private `parseInternal()` core, so it still works; **removal deferred to 5.0** (see below). -- [ ] Deprecate or remove the `getInstance()` singleton (recommend explicit instantiation). +- [x] **Deprecate** the `getInstance()` singleton (recommend explicit instantiation — the static singleton carries process-global state and is pinned to the LEGACY preset). Kept working; **removal deferred to 5.0**. - [x] Removed the deprecated `Parse::validateLocalPart(array)` extension point (deprecated in 3.9) — local-part validation is now a `private`, `ParseContext`-based method, and `validateDomainName()` is `private` too. They took the parser's internal accumulator and were never a supported extension point — validation is customized through `ParseOptions`. **New capabilities (breaking or late-binding):** @@ -88,6 +90,7 @@ Continuous work, not tied to a specific release. ### v5.0 — planned - [ ] Remove the deprecated `parse()` method (deprecated in 4.0). `parseSingle()` / `parseMultiple()` / `parseStream()` are the entry points; the private `parseInternal()` core stays. +- [ ] Remove the deprecated `Parse::getInstance()` singleton (deprecated in 4.0). Use `new Parse($logger, $options)`. ### Backlog (unversioned) diff --git a/docs/cookbook.md b/docs/cookbook.md index 41707a8..2110c71 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -203,7 +203,7 @@ foreach ($addresses as $raw) { } ``` -Prefer explicit instantiation; `Parse::getInstance()` (a singleton with default options) exists for convenience and backward compatibility. +Prefer explicit instantiation — `new Parse()`. `Parse::getInstance()` (a default-options singleton) is **deprecated** and will be removed in 5.0. ## The array shape diff --git a/psalm-baseline.xml b/psalm-baseline.xml index cf9501c..37e860c 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -25,13 +25,6 @@ - - - - - - - diff --git a/src/Parse.php b/src/Parse.php index 6e449e0..a2e1a07 100644 --- a/src/Parse.php +++ b/src/Parse.php @@ -54,7 +54,11 @@ class Parse private ?\Spoofchecker $spoofchecker = null; /** - * Allow Parse to be instantiated as a singleton. + * Return a shared singleton instance configured with default options. + * + * @deprecated 4.0 Prefer explicit instantiation — `new Parse($logger, $options)`. + * The singleton carries process-global state and cannot take custom + * options; it will be removed in 5.0. * * @return Parse The instance */ diff --git a/src/ParseOptions.php b/src/ParseOptions.php index 93b840f..7c48ad4 100644 --- a/src/ParseOptions.php +++ b/src/ParseOptions.php @@ -5,11 +5,11 @@ class ParseOptions { /** @var array */ - private array $bannedChars = []; + public readonly array $bannedChars; /** @var array */ - private array $separators = []; - private bool $useWhitespaceAsSeparator; - private LengthLimits $lengthLimits; + public readonly array $separators; + public readonly bool $useWhitespaceAsSeparator; + public readonly LengthLimits $lengthLimits; /** * Whitespace characters treated as insignificant (folding/separators in * multi-address mode; trimmable). A whitespace character outside this set is @@ -18,15 +18,15 @@ class ParseOptions * * @var array */ - private array $allowedWhitespace = []; + public readonly array $allowedWhitespace; /** * Construct a parser configuration. * - * The first four positional parameters preserve the v2.x / v3.0 signature for - * backward compatibility. The 15 rule properties following them are readonly - * (PHP 8.1) — mutate via the `withX()` fluent builders, which return new - * instances with the change applied. + * The first five positional parameters preserve the v2.x / v3.0 signature for + * backward compatibility. Every property is `readonly` (PHP 8.1) — configure a + * new instance via the `withX()` fluent builders, which return a copy with the + * change applied. (The deprecated mutating setters were removed in 4.0.) * * Default values match legacy (v2.x) parser behavior so `new ParseOptions()` * preserves existing call sites. @@ -85,17 +85,28 @@ public function __construct( public readonly bool $detectConfusableDomain = false, public readonly ?\Closure $localPartNormalizer = null, ) { + // Build the character lookup maps once and assign to the readonly + // properties (readonly forbids the incremental $this->x[$k] = ... writes). + $bannedMap = []; foreach ($bannedChars as $char) { - $this->bannedChars[$char] = true; + $bannedMap[$char] = true; } + $this->bannedChars = $bannedMap; + + $separatorMap = []; foreach ($separators as $sep) { - $this->separators[$sep] = true; + $separatorMap[$sep] = true; } + $this->separators = $separatorMap; + $this->useWhitespaceAsSeparator = $useWhitespaceAsSeparator; $this->lengthLimits = $lengthLimits ?? LengthLimits::createDefault(); + + $whitespaceMap = []; foreach ($allowedWhitespace as $ws) { - $this->allowedWhitespace[$ws] = true; + $whitespaceMap[$ws] = true; } + $this->allowedWhitespace = $whitespaceMap; } /** @return array */ @@ -431,22 +442,7 @@ private function cloneWith(array $overrides): self ); } - // ===== Legacy deprecated setters ===== - // - // These remain as mutating setters for the four non-readonly state fields - // only. They continue to work for v2.x callers; they will be removed in v4.0. - - /** - * @deprecated v3.0 — Use constructor param or withBannedChars(). Removed in v4.0. - * @param array $bannedChars - */ - public function setBannedChars(array $bannedChars): void - { - $this->bannedChars = []; - foreach ($bannedChars as $char) { - $this->bannedChars[$char] = true; - } - } + // ===== Accessors for the state fields ===== /** @return array */ public function getBannedChars(): array @@ -454,86 +450,32 @@ public function getBannedChars(): array return $this->bannedChars; } - /** - * @deprecated v3.0 — Use constructor param or withSeparators(). Removed in v4.0. - * @param array $separators - */ - public function setSeparators(array $separators): void - { - $this->separators = []; - foreach ($separators as $sep) { - $this->separators[$sep] = true; - } - } - /** @return array */ public function getSeparators(): array { return $this->separators; } - /** @deprecated v3.0 — Use constructor param or withUseWhitespaceAsSeparator(). Removed in v4.0. */ - public function setUseWhitespaceAsSeparator(bool $value): void - { - $this->useWhitespaceAsSeparator = $value; - } - public function getUseWhitespaceAsSeparator(): bool { return $this->useWhitespaceAsSeparator; } - /** @deprecated v3.0 — Use constructor param or withLengthLimits(). Removed in v4.0. */ - public function setLengthLimits(LengthLimits $limits): void - { - $this->lengthLimits = $limits; - } - public function getLengthLimits(): LengthLimits { return $this->lengthLimits; } - /** @deprecated v3.0 — Construct a new LengthLimits and pass it. Removed in v4.0. */ - public function setMaxLocalPartLength(int $value): void - { - $this->lengthLimits = new LengthLimits( - $value, - $this->lengthLimits->maxTotalLength, - $this->lengthLimits->maxDomainLabelLength, - ); - } - public function getMaxLocalPartLength(): int { return $this->lengthLimits->maxLocalPartLength; } - /** @deprecated v3.0 — Construct a new LengthLimits and pass it. Removed in v4.0. */ - public function setMaxTotalLength(int $value): void - { - $this->lengthLimits = new LengthLimits( - $this->lengthLimits->maxLocalPartLength, - $value, - $this->lengthLimits->maxDomainLabelLength, - ); - } - public function getMaxTotalLength(): int { return $this->lengthLimits->maxTotalLength; } - /** @deprecated v3.0 — Construct a new LengthLimits and pass it. Removed in v4.0. */ - public function setMaxDomainLabelLength(int $value): void - { - $this->lengthLimits = new LengthLimits( - $this->lengthLimits->maxLocalPartLength, - $this->lengthLimits->maxTotalLength, - $value, - ); - } - public function getMaxDomainLabelLength(): int { return $this->lengthLimits->maxDomainLabelLength; diff --git a/tests/ParseTest.php b/tests/ParseTest.php index a9d231b..b2bc5bd 100644 --- a/tests/ParseTest.php +++ b/tests/ParseTest.php @@ -188,7 +188,7 @@ private function alignReasonCodeOne(array $expected, array $actual): array public function testParseSingleReturnsTypedObject(): void { - $result = Parse::getInstance()->parseSingle('john@example.com'); + $result = (new Parse())->parseSingle('john@example.com'); $this->assertInstanceOf(\Email\ParsedEmailAddress::class, $result); $this->assertSame('john', $result->localPart); $this->assertSame('example.com', $result->domain); @@ -199,14 +199,14 @@ public function testParseSingleReturnsTypedObject(): void public function testParseSingleInvalidCarriesErrorCode(): void { - $result = Parse::getInstance()->parseSingle('foo@bar@baz.com'); + $result = (new Parse())->parseSingle('foo@bar@baz.com'); $this->assertTrue($result->invalid); $this->assertSame(\Email\ParseErrorCode::MultipleAtSymbols, $result->invalidReasonCode); } public function testParseMultipleReturnsTypedResult(): void { - $result = Parse::getInstance()->parseMultiple('a@a.com, b@b.com'); + $result = (new Parse())->parseMultiple('a@a.com, b@b.com'); $this->assertInstanceOf(\Email\ParseResult::class, $result); $this->assertTrue($result->success); $this->assertNull($result->reason); @@ -218,7 +218,7 @@ public function testParseMultipleReturnsTypedResult(): void public function testParseMultipleFailureCarriesReason(): void { - $result = Parse::getInstance()->parseMultiple('a@a.com, not-an-email'); + $result = (new Parse())->parseMultiple('a@a.com, not-an-email'); $this->assertFalse($result->success); $this->assertNotNull($result->reason); $this->assertTrue($result->emailAddresses[1]->invalid); @@ -226,7 +226,7 @@ public function testParseMultipleFailureCarriesReason(): void public function testParsedEmailAddressCommentsAreExtracted(): void { - $result = Parse::getInstance()->parseSingle('user@example.com (home)'); + $result = (new Parse())->parseSingle('user@example.com (home)'); $this->assertSame(['home'], $result->comments); } @@ -640,7 +640,7 @@ public function testNonUtf8EncodingIsHonoredByTokenizer(): void { // "Jörg " with ö as the single ISO-8859-1 byte 0xF6. $input = "J\xF6rg "; - $result = Parse::getInstance()->parseSingle($input, 'ISO-8859-1'); + $result = (new Parse())->parseSingle($input, 'ISO-8859-1'); $this->assertFalse($result->invalid); $this->assertSame('j', $result->localPart); @@ -657,7 +657,7 @@ public function testNonUtf8EncodingIsHonoredByTokenizer(): void public function testVariableWidthEncodingIsHonoredByTokenizer(): void { $input = mb_convert_encoding('日本 ', 'SJIS', 'UTF-8'); - $result = Parse::getInstance()->parseSingle($input, 'SJIS'); + $result = (new Parse())->parseSingle($input, 'SJIS'); $this->assertFalse($result->invalid); $this->assertSame('j', $result->localPart); @@ -679,14 +679,14 @@ public function testMalformedUtf8LocalPartHandling(): void { $loneByte = pack('C', 0x80); // a lone UTF-8 continuation byte $input = 'us'.$loneByte.'er@example.com'; - $result = Parse::getInstance()->parseSingle($input); + $result = (new Parse())->parseSingle($input); if (mb_substr($loneByte, 0, 1, 'UTF-8') === $loneByte) { $this->assertTrue($result->invalid); $this->assertSame(\Email\ParseErrorCode::InvalidUtf8Encoding, $result->invalidReasonCode); } else { // Byte sanitized before validation; result must be deterministic. - $this->assertSame($result->invalid, Parse::getInstance()->parseSingle($input)->invalid); + $this->assertSame($result->invalid, (new Parse())->parseSingle($input)->invalid); } } @@ -697,7 +697,7 @@ public function testMalformedUtf8LocalPartHandling(): void */ public function testLengthBoundariesAcceptMaxAndRejectOneOver(): void { - $p = Parse::getInstance(); + $p = new Parse(); $Err = \Email\ParseErrorCode::class; // Local part: 64 octets is the maximum; 65 is over. @@ -723,7 +723,7 @@ public function testLengthBoundariesAcceptMaxAndRejectOneOver(): void */ public function testPunycodeConversionFailureIsReported(): void { - $result = Parse::getInstance()->parseSingle('user@'.str_repeat('ä', 70).'.de'); + $result = (new Parse())->parseSingle('user@'.str_repeat('ä', 70).'.de'); $this->assertTrue($result->invalid); $this->assertSame(\Email\ParseErrorCode::PunycodeConversionFailed, $result->invalidReasonCode); } @@ -736,7 +736,7 @@ public function testPunycodeConversionFailureIsReported(): void public function testMismatchedEncodingDomainFailsGracefully(): void { $input = mb_convert_encoding('user@日本.com', 'SJIS', 'UTF-8'); - $result = Parse::getInstance()->parseSingle($input, 'SJIS'); + $result = (new Parse())->parseSingle($input, 'SJIS'); $this->assertTrue($result->invalid); $this->assertNotNull($result->invalidReasonCode); } @@ -810,37 +810,30 @@ public function testAllFluentBuildersToggleTheTargetedField(): void } /** - * Exercises the deprecated setters — they continue to work in v3.1 and - * will be removed in v4.0. Coverage-only; assertions verify round-trips. + * The state fields (bannedChars / separators / useWhitespaceAsSeparator / + * lengthLimits) are `public readonly`, configured via the constructor or the + * withX() builders. The deprecated mutating setters were removed in 4.0. */ - public function testDeprecatedSettersStillFunction(): void + public function testStateFieldsArePublicReadonlyAndConfigurable(): void { - $opts = new ParseOptions(); - $opts->setBannedChars(['%']); + $opts = (new ParseOptions()) + ->withBannedChars(['%']) + ->withSeparators([';']) + ->withUseWhitespaceAsSeparator(false) + ->withLengthLimits(new \Email\LengthLimits(10, 20, 5)); + + // Readable via the public readonly properties... + $this->assertSame(['%' => true], $opts->bannedChars); + $this->assertSame([';' => true], $opts->separators); + $this->assertFalse($opts->useWhitespaceAsSeparator); + $this->assertSame(10, $opts->lengthLimits->maxLocalPartLength); + + // ...and via the retained getters. $this->assertSame(['%' => true], $opts->getBannedChars()); - - $opts->setSeparators([';']); $this->assertSame([';' => true], $opts->getSeparators()); - - $opts->setUseWhitespaceAsSeparator(false); $this->assertFalse($opts->getUseWhitespaceAsSeparator()); - - $opts->setLengthLimits(new \Email\LengthLimits(10, 20, 5)); - $this->assertSame(10, $opts->getMaxLocalPartLength()); - $this->assertSame(20, $opts->getMaxTotalLength()); - $this->assertSame(5, $opts->getMaxDomainLabelLength()); - - $opts->setMaxLocalPartLength(64); - $this->assertSame(64, $opts->getMaxLocalPartLength()); - // Other two limits preserved. $this->assertSame(20, $opts->getMaxTotalLength()); $this->assertSame(5, $opts->getMaxDomainLabelLength()); - - $opts->setMaxTotalLength(254); - $this->assertSame(254, $opts->getMaxTotalLength()); - - $opts->setMaxDomainLabelLength(63); - $this->assertSame(63, $opts->getMaxDomainLabelLength()); } /** @@ -873,7 +866,7 @@ public function testStructuralParseErrorsCarryExpectedCode(): void ]; foreach ($cases as [$input, $expected]) { - $result = Parse::getInstance()->parseSingle($input); + $result = (new Parse())->parseSingle($input); $this->assertTrue($result->invalid, "{$input} should be invalid"); $this->assertSame($expected, $result->invalidReasonCode, "{$input} wrong code"); } @@ -1013,7 +1006,7 @@ public function testMultipleInvalidAddressesReasonIsPlural(): void // When a batch contains two or more invalid addresses, the top-level // $reason becomes "Invalid email addresses" (plural) — the second-error // branch on line ~844 of Parse.php flips $reason from the singular form. - $result = Parse::getInstance()->parseMultiple('first-bad@, second-bad@'); + $result = (new Parse())->parseMultiple('first-bad@, second-bad@'); $this->assertFalse($result->success); $this->assertSame('Invalid email addresses', $result->reason); } @@ -1105,7 +1098,7 @@ public function testQuotedStringContentValidation(): void public function testValidAddressHasNullInvalidSeverity(): void { - $result = Parse::getInstance()->parseSingle('user@example.com'); + $result = (new Parse())->parseSingle('user@example.com'); $this->assertFalse($result->invalid); $this->assertNull($result->invalidSeverity()); } @@ -1113,7 +1106,7 @@ public function testValidAddressHasNullInvalidSeverity(): void public function testStructuralFailureIsCriticalSeverity(): void { // Missing '@' — structural failure, unparseable. - $result = Parse::getInstance()->parseSingle('not-an-email'); + $result = (new Parse())->parseSingle('not-an-email'); $this->assertTrue($result->invalid); $this->assertSame(\Email\ValidationSeverity::Critical, $result->invalidSeverity()); } @@ -1127,7 +1120,7 @@ public function testPolicyFailureIsWarningSeverity(): void $this->assertSame(\Email\ValidationSeverity::Warning, $result->invalidSeverity()); // Private-range IP literal is syntactically valid but rejected by the global-range rule. - $result = Parse::getInstance()->parseSingle('user@[192.168.0.1]'); + $result = (new Parse())->parseSingle('user@[192.168.0.1]'); $this->assertTrue($result->invalid); $this->assertSame(\Email\ValidationSeverity::Warning, $result->invalidSeverity()); } @@ -1294,7 +1287,7 @@ public function testEveryErrorCodeHasASeverity(): void public function testParseStreamYieldsTypedObjects(): void { - $parser = Parse::getInstance(); + $parser = new Parse(); $gen = $parser->parseStream(['a@a.com', 'b@b.com']); $this->assertInstanceOf(\Generator::class, $gen); $results = iterator_to_array($gen, false); @@ -1308,7 +1301,7 @@ public function testParseStreamSplitsMultiAddressItems(): void { // Each input item may itself contain several comma-separated addresses; // parseStream yields one ParsedEmailAddress per address regardless. - $parser = Parse::getInstance(); + $parser = new Parse(); $results = iterator_to_array( $parser->parseStream(['a@a.com, b@b.com', 'c@c.com']), false, @@ -1325,7 +1318,7 @@ public function testParseStreamAcceptsGeneratorInput(): void yield 'two@example.com'; })(); - $results = iterator_to_array(Parse::getInstance()->parseStream($input), false); + $results = iterator_to_array((new Parse())->parseStream($input), false); $this->assertCount(2, $results); $this->assertSame('one', $results[0]->localPart); $this->assertSame('two', $results[1]->localPart); @@ -1335,7 +1328,7 @@ public function testParseStreamEmitsInvalidEntries(): void { // Invalid addresses still appear in the stream — callers filter by $addr->invalid. $results = iterator_to_array( - Parse::getInstance()->parseStream(['valid@ok.com', 'not-an-email']), + (new Parse())->parseStream(['valid@ok.com', 'not-an-email']), false, ); $this->assertCount(2, $results); @@ -1423,7 +1416,7 @@ public function testObsRouteWithEmptyAddrSpecIsInvalid(): void public function testValidAddressHasNullObsRoute(): void { // A normal address produces obsRoute=null (not empty string). - $result = Parse::getInstance()->parseSingle('user@example.com'); + $result = (new Parse())->parseSingle('user@example.com'); $this->assertNull($result->obsRoute); } @@ -1436,7 +1429,7 @@ public function testValidAddressHasNullObsRoute(): void public function testCfwsTrailingLocalPart(): void { // "local @domain" — trailing CFWS on local-part dot-atom. - $result = Parse::getInstance()->parseSingle('local @domain.com'); + $result = (new Parse())->parseSingle('local @domain.com'); $this->assertFalse($result->invalid); $this->assertSame('local', $result->localPart); $this->assertSame('domain.com', $result->domain); @@ -1445,7 +1438,7 @@ public function testCfwsTrailingLocalPart(): void public function testCfwsLeadingDomain(): void { // "local@ domain" — leading CFWS on domain dot-atom. - $result = Parse::getInstance()->parseSingle('local@ domain.com'); + $result = (new Parse())->parseSingle('local@ domain.com'); $this->assertFalse($result->invalid); $this->assertSame('local', $result->localPart); $this->assertSame('domain.com', $result->domain); @@ -1453,7 +1446,7 @@ public function testCfwsLeadingDomain(): void public function testCfwsAroundAtSymbol(): void { - $result = Parse::getInstance()->parseSingle('local @ domain.com'); + $result = (new Parse())->parseSingle('local @ domain.com'); $this->assertFalse($result->invalid); $this->assertSame('local', $result->localPart); $this->assertSame('domain.com', $result->domain); @@ -1462,7 +1455,7 @@ public function testCfwsAroundAtSymbol(): void public function testCfwsInsideAngleAddr(): void { // Whitespace inside <> flanking the addr-spec. - $result = Parse::getInstance()->parseSingle('John Doe < local@domain.com >'); + $result = (new Parse())->parseSingle('John Doe < local@domain.com >'); $this->assertFalse($result->invalid); $this->assertSame('John Doe', $result->nameParsed); $this->assertSame('local', $result->localPart); @@ -1471,7 +1464,7 @@ public function testCfwsInsideAngleAddr(): void public function testCfwsAroundAtInsideAngleAddr(): void { - $result = Parse::getInstance()->parseSingle(''); + $result = (new Parse())->parseSingle(''); $this->assertFalse($result->invalid); $this->assertSame('local', $result->localPart); $this->assertSame('domain.com', $result->domain); @@ -1502,7 +1495,7 @@ public function testToArrayRoundTripsLegacyShape(): void public function testToArrayPreservesErrorCode(): void { - $typed = Parse::getInstance()->parseSingle('not-an-email'); + $typed = (new Parse())->parseSingle('not-an-email'); $arr = $typed->toArray(); $this->assertTrue($arr['invalid']); $this->assertInstanceOf(\Email\ParseErrorCode::class, $arr['invalid_reason_code']); @@ -1510,7 +1503,7 @@ public function testToArrayPreservesErrorCode(): void public function testToJsonProducesParseableJson(): void { - $typed = Parse::getInstance()->parseSingle('user@example.com'); + $typed = (new Parse())->parseSingle('user@example.com'); $decoded = json_decode($typed->toJson(), true); $this->assertIsArray($decoded); $this->assertSame('user', $decoded['local_part']); @@ -1520,7 +1513,7 @@ public function testToJsonProducesParseableJson(): void public function testToJsonSerializesErrorCodeAsString(): void { // ParseErrorCode is a BackedEnum; json_encode emits its backing value. - $typed = Parse::getInstance()->parseSingle('<'); + $typed = (new Parse())->parseSingle('<'); $decoded = json_decode($typed->toJson(), true); $this->assertSame('multiple_opening_angle', $decoded['invalid_reason_code']); } @@ -1529,7 +1522,7 @@ public function testToJsonEmitsUnescapedUnicode(): void { // Asserts JSON_UNESCAPED_UNICODE is in the flag set — without it, "münchen" // would become "m\u00fcnchen". Catches bitwise-or regressions in toJson(). - $typed = Parse::getInstance()->parseSingle('user@münchen.de'); + $typed = (new Parse())->parseSingle('user@münchen.de'); $this->assertStringContainsString('münchen', $typed->toJson()); $this->assertStringNotContainsString('\u00', $typed->toJson()); } @@ -1537,33 +1530,33 @@ public function testToJsonEmitsUnescapedUnicode(): void public function testToJsonPassesCallerFlagsThrough(): void { // Caller-supplied flags must reach json_encode (bitwise-or, not &). - $typed = Parse::getInstance()->parseSingle('user@example.com'); + $typed = (new Parse())->parseSingle('user@example.com'); $pretty = $typed->toJson(JSON_PRETTY_PRINT); $this->assertStringContainsString("\n", $pretty); } public function testStringableReturnsSimpleAddressWhenValid(): void { - $typed = Parse::getInstance()->parseSingle('"J Doe" '); + $typed = (new Parse())->parseSingle('"J Doe" '); $this->assertSame('john@example.com', (string) $typed); } public function testStringableReturnsEmptyStringWhenInvalid(): void { - $typed = Parse::getInstance()->parseSingle('not-an-email'); + $typed = (new Parse())->parseSingle('not-an-email'); $this->assertSame('', (string) $typed); } public function testCanonicalAddrSpecWithoutName(): void { - $typed = Parse::getInstance()->parseSingle('john@example.com'); + $typed = (new Parse())->parseSingle('john@example.com'); $this->assertSame('john@example.com', $typed->canonical()); } public function testCanonicalAddrSpecWithSimpleName(): void { // Atext-only name needs no quotes. - $typed = Parse::getInstance()->parseSingle('John Doe '); + $typed = (new Parse())->parseSingle('John Doe '); $this->assertSame('John Doe ', $typed->canonical()); } @@ -1571,27 +1564,27 @@ public function testCanonicalStripsUnnecessaryNameQuotes(): void { // Input had quotes; canonical form drops them because the name is // pure atext+WSP and quoting is not required per RFC 5322 §3.2.5. - $typed = Parse::getInstance()->parseSingle('"John Doe" '); + $typed = (new Parse())->parseSingle('"John Doe" '); $this->assertSame('John Doe ', $typed->canonical()); } public function testCanonicalKeepsRequiredNameQuotes(): void { // Period in display name requires quoting (it's not atext). - $typed = Parse::getInstance()->parseSingle('"John Q. Public" '); + $typed = (new Parse())->parseSingle('"John Q. Public" '); $this->assertSame('"John Q. Public" ', $typed->canonical()); } public function testCanonicalQuotesLocalPartWhenRequired(): void { // Local-part with a space must be quoted per RFC 5322 §3.2.4. - $typed = Parse::getInstance()->parseSingle('"with space"@example.com'); + $typed = (new Parse())->parseSingle('"with space"@example.com'); $this->assertSame('"with space"@example.com', $typed->canonical()); } public function testCanonicalReturnsEmptyForInvalidAddress(): void { - $typed = Parse::getInstance()->parseSingle('not-an-email'); + $typed = (new Parse())->parseSingle('not-an-email'); $this->assertSame('', $typed->canonical()); } @@ -1606,7 +1599,7 @@ public function testParseResultToArrayRoundTripsLegacyShape(): void public function testParseResultToJsonProducesParseableJson(): void { - $typed = Parse::getInstance()->parseMultiple('a@a.com, b@b.com'); + $typed = (new Parse())->parseMultiple('a@a.com, b@b.com'); $decoded = json_decode($typed->toJson(), true); $this->assertTrue($decoded['success']); $this->assertCount(2, $decoded['email_addresses']); @@ -1615,14 +1608,14 @@ public function testParseResultToJsonProducesParseableJson(): void public function testParseResultToJsonEmitsUnescapedUnicode(): void { - $typed = Parse::getInstance()->parseMultiple('user@münchen.de'); + $typed = (new Parse())->parseMultiple('user@münchen.de'); $this->assertStringContainsString('münchen', $typed->toJson()); $this->assertStringNotContainsString('\u00', $typed->toJson()); } public function testParseResultToJsonPassesCallerFlagsThrough(): void { - $typed = Parse::getInstance()->parseMultiple('a@a.com, b@b.com'); + $typed = (new Parse())->parseMultiple('a@a.com, b@b.com'); $this->assertStringContainsString("\n", $typed->toJson(JSON_PRETTY_PRINT)); } diff --git a/tests/PropertyTest.php b/tests/PropertyTest.php index 91d9be9..26cecf6 100644 --- a/tests/PropertyTest.php +++ b/tests/PropertyTest.php @@ -78,7 +78,7 @@ private function randomEmailLike(): string */ public function testParseSingleNeverThrows(): void { - $parser = Parse::getInstance(); + $parser = new Parse(); for ($i = 0; $i < self::ITERATIONS; $i++) { $input = $this->randomString(); $result = $parser->parseSingle($input); @@ -91,7 +91,7 @@ public function testParseSingleNeverThrows(): void */ public function testParseMultipleNeverThrows(): void { - $parser = Parse::getInstance(); + $parser = new Parse(); for ($i = 0; $i < self::ITERATIONS; $i++) { $result = $parser->parseMultiple($this->randomString()); $this->assertIsBool($result->success); @@ -118,7 +118,7 @@ public function testParseIsDeterministic(): void */ public function testInvalidImpliesBothReasonAndCode(): void { - $parser = Parse::getInstance(); + $parser = new Parse(); for ($i = 0; $i < self::ITERATIONS; $i++) { $s = $this->randomString(); $r = $parser->parseSingle($s); @@ -137,7 +137,7 @@ public function testInvalidImpliesBothReasonAndCode(): void */ public function testInvalidAlwaysHasSeverity(): void { - $parser = Parse::getInstance(); + $parser = new Parse(); for ($i = 0; $i < self::ITERATIONS; $i++) { $r = $parser->parseSingle($this->randomString()); if ($r->invalid) { @@ -153,7 +153,7 @@ public function testInvalidAlwaysHasSeverity(): void */ public function testStringableContract(): void { - $parser = Parse::getInstance(); + $parser = new Parse(); for ($i = 0; $i < self::ITERATIONS; $i++) { $r = $parser->parseSingle($this->randomString()); $expected = $r->invalid ? '' : $r->simpleAddress; From 2b066a42f35b058de4187a4bf10b6855233cbf4e Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Wed, 26 Aug 2026 00:09:01 -0700 Subject: [PATCH 04/23] 4.0: rename ParseContext accumulator fields snake_case -> camelCase Aligns the internal accumulator with the codebase's camelCase convention. Mechanical rename of ~20 fields across Parse.php and ParseContext.php (property declarations + all $ctx->/$this-> accesses). The public snake_case output-array keys are string literals in addAddress() and are untouched, so parse() / toArray() output is byte-identical. 109 tests / 7183 assertions (testspec output unchanged), PHPStan L8, Psalm, CS all green. --- ROADMAP.md | 2 +- src/Parse.php | 518 +++++++++++++++++++++---------------------- src/ParseContext.php | 83 ++++--- 3 files changed, 301 insertions(+), 302 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f0de0c4..fb22beb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -95,7 +95,7 @@ Continuous work, not tied to a specific release. ### Backlog (unversioned) - [ ] **`parse()` refactor & modernization follow-ups** (from review; non-blocking, each behavior-preserving and test-gated): - - [ ] Rename `ParseContext`'s accumulator fields `snake_case` → `camelCase` to match the codebase. Output-array keys stay `snake_case` (public API); only the internal properties change. Kept as-is during extraction so the diff was a pure move. + - [x] Renamed `ParseContext`'s accumulator fields `snake_case` → `camelCase` to match the codebase. Output-array keys stay `snake_case` (public API, string literals in `addAddress()`); only the internal properties changed. - [ ] **Encode `ParseContext`'s three concerns structurally.** The immutable input snapshot (`chars`/`len`/`emails`), the read-only hoisted config (`separators`, `bannedChars`, …), and the mutable per-address accumulator are all plain public fields today, so nothing stops a handler writing config. Promote the snapshot + config to `readonly` (constructor-promoted) so only the accumulator stays mutable — the clearest SOTA/correctness win, but it needs `parse()`'s construction reworked (the snapshot is currently assigned after `new`). - [ ] **Consider a `ParserState: int` backed enum** in place of the 13 `STATE_*` int constants. Gives type-safety on `$ctx->state`/`$subState` and would likely retire the Psalm state-narrowing baseline entries. Gate on a benchmark: the dispatch is a hot loop, so measure enum-vs-int comparison/array-key overhead before committing (the no-regression constraint still applies). - [ ] Drop the `chars`/`len` double source of truth (loop locals vs context properties — kept for hot-loop locality; measure before changing). diff --git a/src/Parse.php b/src/Parse.php index a2e1a07..533bf21 100644 --- a/src/Parse.php +++ b/src/Parse.php @@ -371,17 +371,17 @@ private function parseInternal(string $emails, bool $multiple, string $encoding) break; default: // Shouldn't ever get here - what is $ctx->state? - $ctx->original_address .= $curChar; + $ctx->originalAddress .= $curChar; $ctx->invalid = true; - $ctx->invalid_reason = 'Error during parsing'; - $ctx->invalid_reason_code = Err::ParseError; + $ctx->invalidReason = 'Error during parsing'; + $ctx->invalidReasonCode = Err::ParseError; $this->log('error', "Email\\Parse->parse - error during parsing - \$state: {$ctx->state}\n\$subState: {$ctx->subState}\n\$i: {$i}\n\$curChar: {$curChar}"); break; } - // if there's a $ctx->original_address and the state is set to STATE_END_ADDRESS - if (self::STATE_END_ADDRESS == $ctx->state && strlen($ctx->original_address) > 0) { + // if there's a $ctx->originalAddress and the state is set to STATE_END_ADDRESS + if (self::STATE_END_ADDRESS == $ctx->state && strlen($ctx->originalAddress) > 0) { $invalid = $this->addAddress( $emailAddresses, $ctx, @@ -407,7 +407,7 @@ private function parseInternal(string $emails, bool $multiple, string $encoding) // (even under a NullLogger, the argument is still built) makes malformed input // O(n^2). See the DoS regression benchmark. if ($ctx->invalid && self::STATE_SKIP_AHEAD !== $ctx->state) { - $this->log('debug', "Email\\Parse->parse - invalid - {$ctx->invalid_reason}\n\$ctx->original_address {$ctx->original_address}\n\$emails: {$emails}"); + $this->log('debug', "Email\\Parse->parse - invalid - {$ctx->invalidReason}\n\$ctx->originalAddress {$ctx->originalAddress}\n\$emails: {$emails}"); $ctx->state = self::STATE_SKIP_AHEAD; } } @@ -418,18 +418,18 @@ private function parseInternal(string $emails, bool $multiple, string $encoding) // buffered elsewhere (a closed delimiter always returns to STATE_ADDRESS). if (!$ctx->invalid && in_array($ctx->state, [self::STATE_QUOTE, self::STATE_COMMENT, self::STATE_SQUARE_BRACKET, self::STATE_OBS_ROUTE], true)) { $ctx->invalid = true; - [$ctx->invalid_reason, $ctx->invalid_reason_code] = match ($ctx->state) { + [$ctx->invalidReason, $ctx->invalidReasonCode] = match ($ctx->state) { self::STATE_QUOTE => ['No ending quote: \'"\'', Err::UnterminatedQuote], self::STATE_COMMENT => ['No closing parenthesis: \')\'', Err::UnterminatedComment], self::STATE_SQUARE_BRACKET => ['No closing square bracket: \']\'', Err::UnterminatedSquareBracket], self::STATE_OBS_ROUTE => ['Incomplete obs-route: missing colon before end of input', Err::IncompleteAddress], }; } - if (!$ctx->invalid && ($ctx->address_temp || $ctx->quote_temp)) { - $this->log('error', "Email\\Parse->parse - corruption during parsing - leftovers:\n\$i: {$i}\n\$ctx->address_temp: {$ctx->address_temp}\n\$ctx->quote_temp: {$ctx->quote_temp}\nEmails: {$emails}"); + if (!$ctx->invalid && ($ctx->addressTemp || $ctx->quoteTemp)) { + $this->log('error', "Email\\Parse->parse - corruption during parsing - leftovers:\n\$i: {$i}\n\$ctx->addressTemp: {$ctx->addressTemp}\n\$ctx->quoteTemp: {$ctx->quoteTemp}\nEmails: {$emails}"); $ctx->invalid = true; - $ctx->invalid_reason = 'Incomplete address'; - $ctx->invalid_reason_code = Err::IncompleteAddress; + $ctx->invalidReason = 'Incomplete address'; + $ctx->invalidReasonCode = Err::IncompleteAddress; if (!$success) { $reason = 'Invalid email addresses'; } else { @@ -441,20 +441,20 @@ private function parseInternal(string $emails, bool $multiple, string $encoding) // Did we find no email addresses at all? An empty local-part only counts as // "no address" when it is unquoted; `""@domain` is a legitimately-empty quoted // local-part whose acceptance is decided later by rejectEmptyQuotedLocalPart. - if (!$ctx->invalid && !count($emailAddresses) && (!$ctx->original_address || (!$ctx->local_part_parsed && !$ctx->local_part_quoted))) { + if (!$ctx->invalid && !count($emailAddresses) && (!$ctx->originalAddress || (!$ctx->localPartParsed && !$ctx->localPartQuoted))) { $success = false; $reason = 'No email addresses found'; if (!$multiple) { $ctx->invalid = true; - $ctx->invalid_reason = 'No email address found'; - $ctx->invalid_reason_code = Err::IncompleteAddress; + $ctx->invalidReason = 'No email address found'; + $ctx->invalidReasonCode = Err::IncompleteAddress; $this->addAddress( $emailAddresses, $ctx, $i ); } - } elseif ($ctx->original_address) { + } elseif ($ctx->originalAddress) { $invalid = $this->addAddress( $emailAddresses, $ctx, @@ -487,7 +487,7 @@ private function handleStateSkipAhead(ParseContext $ctx, string $curChar): void if ($ctx->multiple && ($isWhitespaceSeparator || isset($ctx->separators[$curChar]))) { $ctx->state = self::STATE_END_ADDRESS; } else { - $ctx->original_address .= $curChar; + $ctx->originalAddress .= $curChar; } } @@ -504,13 +504,13 @@ private function handleStateTrim(ParseContext $ctx, string $curChar): bool } $ctx->state = self::STATE_ADDRESS; if ('"' == $curChar) { - $ctx->original_address .= $curChar; + $ctx->originalAddress .= $curChar; $ctx->state = self::STATE_QUOTE; return false; } if ('(' == $curChar) { - $ctx->original_address .= $curChar; + $ctx->originalAddress .= $curChar; $ctx->state = self::STATE_COMMENT; // A leading comment opens at nest level 1 (matches the // STATE_ADDRESS entry); without this an unbalanced nested @@ -532,29 +532,29 @@ private function handleStateTrim(ParseContext $ctx, string $curChar): bool private function handleStateAddress(ParseContext $ctx, string $curChar, ?string $prevChar, int $i): void { if (!isset($ctx->separators[$curChar]) || !$ctx->multiple) { - $ctx->original_address .= $curChar; + $ctx->originalAddress .= $curChar; } - if ($ctx->after_closing_quote) { - $ctx->after_closing_quote = false; + if ($ctx->afterClosingQuote) { + $ctx->afterClosingQuote = false; // RFC 5322 §3.2.4: a quoted-string is a whole word. Only a dot // (obs word.word), '@', angle brackets, CFWS, or a separator may // follow it — atext or a second quote directly abutting it is invalid. if ('"' === $curChar || $curChar > "\x7f" || preg_match('/[A-Za-z0-9_\-!#$%&\'*+\/=?^`{|}~]/', $curChar)) { $ctx->invalid = true; - $ctx->invalid_reason = 'A quoted string in the local part must be followed by a dot, "@", or the end — text or a second quote cannot immediately follow it'; - $ctx->invalid_reason_code = Err::AtextAfterQuotedString; + $ctx->invalidReason = 'A quoted string in the local part must be followed by a dot, "@", or the end — text or a second quote cannot immediately follow it'; + $ctx->invalidReasonCode = Err::AtextAfterQuotedString; } } - if ($ctx->comment_after_local_atext) { - $ctx->comment_after_local_atext = false; + if ($ctx->commentAfterLocalAtext) { + $ctx->commentAfterLocalAtext = false; // atext or a second quoted-string resuming the word after a comment. // Defer the verdict: it is only an error if this turns out to be an // addr-spec local part (resolved at '@'); in a display-name phrase // "word CFWS word" is legal and is cleared at '<'. if ('"' === $curChar || $curChar > "\x7f" || preg_match('/[A-Za-z0-9_\-!#$%&\'*+\/=?^`{|}~]/', $curChar)) { - $ctx->local_atom_split_by_comment = true; + $ctx->localAtomSplitByComment = true; } } @@ -574,11 +574,11 @@ private function handleStateAddress(ParseContext $ctx, string $curChar, ?string } else { $ctx->invalid = true; if ($ctx->multiple || ($i + 5) >= $ctx->len) { - $ctx->invalid_reason = 'Misplaced separator or missing "@" symbol'; - $ctx->invalid_reason_code = Err::MisplacedSeparator; + $ctx->invalidReason = 'Misplaced separator or missing "@" symbol'; + $ctx->invalidReasonCode = Err::MisplacedSeparator; } else { - $ctx->invalid_reason = 'Separator not permitted - only one email address allowed'; - $ctx->invalid_reason_code = Err::SeparatorNotPermitted; + $ctx->invalidReason = 'Separator not permitted - only one email address allowed'; + $ctx->invalidReasonCode = Err::SeparatorNotPermitted; } } } elseif (isset($ctx->allowedWhitespace[$curChar])) { @@ -589,20 +589,20 @@ private function handleStateAddress(ParseContext $ctx, string $curChar, ?string // Start of the local part if (self::STATE_LOCAL_PART == $ctx->subState || self::STATE_DOMAIN == $ctx->subState) { $ctx->invalid = true; - $ctx->invalid_reason = 'Email address contains multiple opening "<" (either a typo or multiple emails that need to be separated by a comma or space)'; - $ctx->invalid_reason_code = Err::MultipleOpeningAngle; + $ctx->invalidReason = 'Email address contains multiple opening "<" (either a typo or multiple emails that need to be separated by a comma or space)'; + $ctx->invalidReasonCode = Err::MultipleOpeningAngle; } else { // Here should be the start of the local part for sure everything else then is part of the name $ctx->subState = self::STATE_LOCAL_PART; - $ctx->special_char_in_substate = null; - $ctx->in_angle_addr = true; + $ctx->specialCharInSubstate = null; + $ctx->inAngleAddr = true; // Any quote before `<` was the display name, not the local part; // clear the quoted flag the closing-quote handler set so the real // local-part inside the angle-addr starts unquoted. Likewise any // comment before `<` sat in the display-name phrase (legal there), // not an addr-spec local part — clear the deferred split marker. - $ctx->local_part_quoted = false; - $ctx->local_atom_split_by_comment = false; + $ctx->localPartQuoted = false; + $ctx->localAtomSplitByComment = false; $this->handleQuote($ctx); } } elseif ('>' == $curChar) { @@ -615,18 +615,18 @@ private function handleStateAddress(ParseContext $ctx, string $curChar, ?string || (self::STATE_AFTER_DOMAIN == $ctx->subState && ('' !== $ctx->domain || '' !== $ctx->ip))) { $ctx->subState = self::STATE_AFTER_DOMAIN; - $ctx->in_angle_addr = false; + $ctx->inAngleAddr = false; } else { $ctx->invalid = true; - $ctx->invalid_reason = "Did not find domain name before a closing '>'"; - $ctx->invalid_reason_code = Err::MissingDomainBeforeClosingAngle; + $ctx->invalidReason = "Did not find domain name before a closing '>'"; + $ctx->invalidReasonCode = Err::MissingDomainBeforeClosingAngle; } } elseif ('"' == $curChar) { // If we hit a quote - change to the quote state, unless it's in the domain, in which case it's error if (self::STATE_DOMAIN == $ctx->subState || self::STATE_AFTER_DOMAIN == $ctx->subState) { $ctx->invalid = true; - $ctx->invalid_reason = 'Quote \'"\' found where it shouldn\'t be'; - $ctx->invalid_reason_code = Err::MisplacedQuote; + $ctx->invalidReason = 'Quote \'"\' found where it shouldn\'t be'; + $ctx->invalidReasonCode = Err::MisplacedQuote; } else { $ctx->state = self::STATE_QUOTE; } @@ -640,12 +640,12 @@ private function handleStateAddress(ParseContext $ctx, string $curChar, ?string // internal "parser confusion" error. if (self::STATE_DOMAIN != $ctx->subState) { $ctx->invalid = true; - $ctx->invalid_reason = "Invalid character '[' in email address"; - $ctx->invalid_reason_code = Err::InvalidOpeningBracket; + $ctx->invalidReason = "Invalid character '[' in email address"; + $ctx->invalidReasonCode = Err::InvalidOpeningBracket; } elseif ('' !== $ctx->domain || '' !== $ctx->ip) { $ctx->invalid = true; - $ctx->invalid_reason = "A domain literal '[...]' must be the entire domain, not combined with other domain characters"; - $ctx->invalid_reason_code = Err::InvalidOpeningBracket; + $ctx->invalidReason = "A domain literal '[...]' must be the entire domain, not combined with other domain characters"; + $ctx->invalidReasonCode = Err::InvalidOpeningBracket; } else { $ctx->state = self::STATE_SQUARE_BRACKET; } @@ -654,37 +654,37 @@ private function handleStateAddress(ParseContext $ctx, string $curChar, ?string if ('.' == $prevChar && !$this->options->allowObsLocalPart) { // Consecutive dots only allowed when obs-local-part is enabled $ctx->invalid = true; - $ctx->invalid_reason = "Email address should not contain two dots '.' in a row"; - $ctx->invalid_reason_code = Err::ConsecutiveDots; + $ctx->invalidReason = "Email address should not contain two dots '.' in a row"; + $ctx->invalidReasonCode = Err::ConsecutiveDots; } elseif (self::STATE_LOCAL_PART == $ctx->subState) { - if (!$ctx->local_part_parsed && !$this->options->allowObsLocalPart) { + if (!$ctx->localPartParsed && !$this->options->allowObsLocalPart) { // Leading dots only allowed when obs-local-part is enabled $ctx->invalid = true; - $ctx->invalid_reason = "Email address can not start with '.'"; - $ctx->invalid_reason_code = Err::LeadingDot; + $ctx->invalidReason = "Email address can not start with '.'"; + $ctx->invalidReasonCode = Err::LeadingDot; } else { - $ctx->local_part_parsed .= $curChar; + $ctx->localPartParsed .= $curChar; } } elseif (self::STATE_DOMAIN == $ctx->subState) { $ctx->domain .= $curChar; } elseif (self::STATE_AFTER_DOMAIN == $ctx->subState) { $ctx->invalid = true; - $ctx->invalid_reason = "Stray period '.' found after domain of email address"; - $ctx->invalid_reason_code = Err::StrayPeriodAfterDomain; + $ctx->invalidReason = "Stray period '.' found after domain of email address"; + $ctx->invalidReasonCode = Err::StrayPeriodAfterDomain; } elseif (self::STATE_START == $ctx->subState) { - if ($ctx->quote_temp) { - $ctx->address_temp .= $ctx->quote_temp; - $ctx->address_temp_quoted = true; - $ctx->quote_temp = ''; + if ($ctx->quoteTemp) { + $ctx->addressTemp .= $ctx->quoteTemp; + $ctx->addressTempQuoted = true; + $ctx->quoteTemp = ''; } - $ctx->address_temp .= $curChar; - ++$ctx->address_temp_period; + $ctx->addressTemp .= $curChar; + ++$ctx->addressTempPeriod; } else { // RFC 5322 §3.4: a period is not an atext character and is not // valid in an unquoted display name or at the start of an address. $ctx->invalid = true; - $ctx->invalid_reason = 'Stray period found in email address. If the period is part of a person\'s name, it must appear in double quotes - e.g. "John Q. Public". Otherwise, an email address shouldn\'t begin with a period.'; - $ctx->invalid_reason_code = Err::StrayPeriod; + $ctx->invalidReason = 'Stray period found in email address. If the period is part of a person\'s name, it must appear in double quotes - e.g. "John Q. Public". Otherwise, an email address shouldn\'t begin with a period.'; + $ctx->invalidReasonCode = Err::StrayPeriod; } } elseif (preg_match('/[A-Za-z0-9_\-!#$%&\'*+\/=?^`{|}~]/', $curChar)) { // atext (RFC 5322 §3.2.3) — the per-character hot path; inlined to keep @@ -692,38 +692,38 @@ private function handleStateAddress(ParseContext $ctx, string $curChar, ?string // domain or pending word per the sub-state. if (isset($ctx->bannedChars[$curChar])) { $ctx->invalid = true; - $ctx->invalid_reason = "This character is not allowed in email addresses submitted (please put in quotes if needed): '{$curChar}'"; - $ctx->invalid_reason_code = Err::CharacterNotAllowed; + $ctx->invalidReason = "This character is not allowed in email addresses submitted (please put in quotes if needed): '{$curChar}'"; + $ctx->invalidReasonCode = Err::CharacterNotAllowed; } elseif (('/' == $curChar || '|' == $curChar) && - !$ctx->local_part_parsed && !$ctx->address_temp && !$ctx->quote_temp && !$ctx->name_parsed) { + !$ctx->localPartParsed && !$ctx->addressTemp && !$ctx->quoteTemp && !$ctx->nameParsed) { $ctx->invalid = true; - $ctx->invalid_reason = "This character is not allowed at the beginning of an email address (please put in quotes if needed): '{$curChar}'"; - $ctx->invalid_reason_code = Err::InvalidCharacterAtStart; + $ctx->invalidReason = "This character is not allowed at the beginning of an email address (please put in quotes if needed): '{$curChar}'"; + $ctx->invalidReasonCode = Err::InvalidCharacterAtStart; } elseif (self::STATE_LOCAL_PART == $ctx->subState) { // Legitimate character - Determine where to append based on the current 'substate' - if ($ctx->quote_temp) { - $ctx->local_part_parsed .= $ctx->quote_temp; - $ctx->quote_temp = ''; - $ctx->local_part_quoted = true; + if ($ctx->quoteTemp) { + $ctx->localPartParsed .= $ctx->quoteTemp; + $ctx->quoteTemp = ''; + $ctx->localPartQuoted = true; } - $ctx->local_part_parsed .= $curChar; + $ctx->localPartParsed .= $curChar; } elseif (self::STATE_NAME == $ctx->subState) { - if ($ctx->quote_temp) { - $ctx->name_parsed .= $ctx->quote_temp; - $ctx->quote_temp = ''; - $ctx->name_quoted = true; + if ($ctx->quoteTemp) { + $ctx->nameParsed .= $ctx->quoteTemp; + $ctx->quoteTemp = ''; + $ctx->nameQuoted = true; } - $ctx->name_parsed .= $curChar; + $ctx->nameParsed .= $curChar; } elseif (self::STATE_DOMAIN == $ctx->subState) { $ctx->domain .= $curChar; } else { - if ($ctx->quote_temp) { - $ctx->address_temp .= $ctx->quote_temp; - $ctx->address_temp_quoted = true; - $ctx->quote_temp = ''; + if ($ctx->quoteTemp) { + $ctx->addressTemp .= $ctx->quoteTemp; + $ctx->addressTempQuoted = true; + $ctx->quoteTemp = ''; } - $ctx->address_temp .= $curChar; + $ctx->addressTemp .= $curChar; } } else { $this->handleAddressNonAtext($ctx, $curChar); @@ -773,10 +773,10 @@ private function handleAddressWhitespace(ParseContext $ctx, string $curChar, int // Trailing CFWS of the local-part dot-atom: "local @domain". $cfwsAbsorbed = true; } elseif ( - $ctx->in_angle_addr - && $ctx->local_part_parsed === '' - && $ctx->address_temp === '' - && $ctx->quote_temp === '' + $ctx->inAngleAddr + && $ctx->localPartParsed === '' + && $ctx->addressTemp === '' + && $ctx->quoteTemp === '' ) { // Leading CFWS inside angle-addr: "< local@domain>". $cfwsAbsorbed = true; @@ -789,7 +789,7 @@ private function handleAddressWhitespace(ParseContext $ctx, string $curChar, int } elseif ( self::STATE_START === $ctx->subState && '@' === $lookAheadChar - && $ctx->address_temp !== '' + && $ctx->addressTemp !== '' ) { // Top-level addr-spec with no angle-addr: "local @domain". // The accumulated address_temp IS the local-part; absorb the @@ -805,11 +805,11 @@ private function handleAddressWhitespace(ParseContext $ctx, string $curChar, int $ctx->subState = self::STATE_AFTER_DOMAIN; } elseif (self::STATE_LOCAL_PART == $ctx->subState) { $ctx->invalid = true; - $ctx->invalid_reason = 'Email address contains whitespace'; - $ctx->invalid_reason_code = Err::WhitespaceInAddress; + $ctx->invalidReason = 'Email address contains whitespace'; + $ctx->invalidReasonCode = Err::WhitespaceInAddress; } } elseif ( - $ctx->in_angle_addr + $ctx->inAngleAddr && self::STATE_DOMAIN == $ctx->subState && $lookAheadChar === '>' ) { @@ -838,8 +838,8 @@ private function handleAddressWhitespace(ParseContext $ctx, string $curChar, int for ($k = $i; $k < $ctx->len && isset(self::WHITESPACE[$ctx->chars[$k]]); ++$k) { if (!isset($ctx->allowedWhitespace[$ctx->chars[$k]])) { $ctx->invalid = true; - $ctx->invalid_reason = 'Disallowed whitespace after address'; - $ctx->invalid_reason_code = Err::WhitespaceInAddress; + $ctx->invalidReason = 'Disallowed whitespace after address'; + $ctx->invalidReasonCode = Err::WhitespaceInAddress; break; } @@ -851,12 +851,12 @@ private function handleAddressWhitespace(ParseContext $ctx, string $curChar, int } else { if (self::STATE_LOCAL_PART == $ctx->subState) { $ctx->invalid = true; - $ctx->invalid_reason = 'Email address contains whitespace'; - $ctx->invalid_reason_code = Err::WhitespaceInAddress; + $ctx->invalidReason = 'Email address contains whitespace'; + $ctx->invalidReasonCode = Err::WhitespaceInAddress; } else { // Display-name phrase: absorb into name_parsed. $this->handleQuote($ctx); - $ctx->name_parsed .= $curChar; + $ctx->nameParsed .= $curChar; } } @@ -871,39 +871,39 @@ private function handleAddressAt(ParseContext $ctx): void { if (self::STATE_DOMAIN == $ctx->subState) { $ctx->invalid = true; - $ctx->invalid_reason = "Multiple at '@' symbols in email address"; - $ctx->invalid_reason_code = Err::MultipleAtSymbols; + $ctx->invalidReason = "Multiple at '@' symbols in email address"; + $ctx->invalidReasonCode = Err::MultipleAtSymbols; } elseif (self::STATE_AFTER_DOMAIN == $ctx->subState) { $ctx->invalid = true; - $ctx->invalid_reason = "Stray at '@' symbol found after domain name"; - $ctx->invalid_reason_code = Err::StrayAtAfterDomain; - } elseif (null !== $ctx->special_char_in_substate) { + $ctx->invalidReason = "Stray at '@' symbol found after domain name"; + $ctx->invalidReasonCode = Err::StrayAtAfterDomain; + } elseif (null !== $ctx->specialCharInSubstate) { $ctx->invalid = true; - $ctx->invalid_reason = "Invalid character found in email address local part: '{$ctx->special_char_in_substate}'"; - $ctx->invalid_reason_code = Err::InvalidCharacterInLocalPart; - } elseif ($ctx->local_atom_split_by_comment) { + $ctx->invalidReason = "Invalid character found in email address local part: '{$ctx->specialCharInSubstate}'"; + $ctx->invalidReasonCode = Err::InvalidCharacterInLocalPart; + } elseif ($ctx->localAtomSplitByComment) { // The `@` confirms this was an addr-spec local part, so the comment // that split its atext (RFC 5322 §3.2.3) is invalid here. $ctx->invalid = true; - $ctx->invalid_reason = 'A comment cannot appear between characters of an unquoted local part; separate with a dot or quote the local part'; - $ctx->invalid_reason_code = Err::AtextAfterComment; + $ctx->invalidReason = 'A comment cannot appear between characters of an unquoted local part; separate with a dot or quote the local part'; + $ctx->invalidReasonCode = Err::AtextAfterComment; } elseif ( $this->options->allowObsRoute - && $ctx->in_angle_addr - && $ctx->obs_route === '' - && $ctx->local_part_parsed === '' - && $ctx->quote_temp === '' - && $ctx->address_temp === '' + && $ctx->inAngleAddr + && $ctx->obsRoute === '' + && $ctx->localPartParsed === '' + && $ctx->quoteTemp === '' + && $ctx->addressTemp === '' // An empty *quoted* local part (`<""@host>`) is a real local // part, not the "no local part" that starts an obs-route. - && !$ctx->local_part_quoted + && !$ctx->localPartQuoted ) { // RFC 5322 §4.4 obs-route: first `@` seen inside `<...>` with no // preceding local-part starts the source-route prefix. Consume // the remainder until `:` via STATE_OBS_ROUTE, then resume // addr-spec parsing with local-part reset. $ctx->state = self::STATE_OBS_ROUTE; - $ctx->obs_route = '@'; + $ctx->obsRoute = '@'; } else { $ctx->subState = self::STATE_DOMAIN; // A trailing quoted word after earlier words ("x"."y", x."y") @@ -911,21 +911,21 @@ private function handleAddressAt(ParseContext $ctx): void // word *("." word), word = atom / quoted-string). Flush it onto // the accumulated local part, exactly as the dot handler flushes // earlier words — not a parser error. - if ($ctx->address_temp && $ctx->quote_temp) { - $ctx->address_temp .= $ctx->quote_temp; - $ctx->address_temp_quoted = true; - $ctx->quote_temp = ''; + if ($ctx->addressTemp && $ctx->quoteTemp) { + $ctx->addressTemp .= $ctx->quoteTemp; + $ctx->addressTempQuoted = true; + $ctx->quoteTemp = ''; } - if ($ctx->quote_temp) { - $ctx->local_part_parsed = $ctx->quote_temp; - $ctx->quote_temp = ''; - $ctx->local_part_quoted = true; - } elseif ($ctx->address_temp) { - $ctx->local_part_parsed = $ctx->address_temp; - $ctx->address_temp = ''; - $ctx->local_part_quoted = $ctx->address_temp_quoted; - $ctx->address_temp_quoted = false; - $ctx->address_temp_period = 0; + if ($ctx->quoteTemp) { + $ctx->localPartParsed = $ctx->quoteTemp; + $ctx->quoteTemp = ''; + $ctx->localPartQuoted = true; + } elseif ($ctx->addressTemp) { + $ctx->localPartParsed = $ctx->addressTemp; + $ctx->addressTemp = ''; + $ctx->localPartQuoted = $ctx->addressTempQuoted; + $ctx->addressTempQuoted = false; + $ctx->addressTempPeriod = 0; } } } @@ -950,24 +950,24 @@ private function handleAddressNonAtext(ParseContext $ctx, string $curChar): void $ctx->invalid = true; } } catch (\Exception $e) { - $this->log('warning', "Email\\Parse->parse - exception trying to convert character '{$curChar}' to punycode\n\$ctx->original_address: {$ctx->original_address}\n\$emails: {$ctx->emails}"); + $this->log('warning', "Email\\Parse->parse - exception trying to convert character '{$curChar}' to punycode\n\$ctx->originalAddress: {$ctx->originalAddress}\n\$emails: {$ctx->emails}"); $ctx->invalid = true; } if ($ctx->invalid) { - $ctx->invalid_reason = "Invalid character found in domain of email address (please put in quotes if needed): '{$curChar}'"; - $ctx->invalid_reason_code = Err::InvalidCharacterInDomain; + $ctx->invalidReason = "Invalid character found in domain of email address (please put in quotes if needed): '{$curChar}'"; + $ctx->invalidReasonCode = Err::InvalidCharacterInDomain; } } } elseif (self::STATE_START === $ctx->subState || self::STATE_LOCAL_PART === $ctx->subState) { // Handle non-atext characters in both STATE_START and STATE_LOCAL_PART consistently - if ($ctx->subState === self::STATE_START && $ctx->quote_temp) { - $ctx->address_temp .= $ctx->quote_temp; - $ctx->address_temp_quoted = true; - $ctx->quote_temp = ''; - } elseif ($ctx->subState === self::STATE_LOCAL_PART && $ctx->quote_temp) { - $ctx->local_part_parsed .= $ctx->quote_temp; - $ctx->quote_temp = ''; - $ctx->local_part_quoted = true; + if ($ctx->subState === self::STATE_START && $ctx->quoteTemp) { + $ctx->addressTemp .= $ctx->quoteTemp; + $ctx->addressTempQuoted = true; + $ctx->quoteTemp = ''; + } elseif ($ctx->subState === self::STATE_LOCAL_PART && $ctx->quoteTemp) { + $ctx->localPartParsed .= $ctx->quoteTemp; + $ctx->quoteTemp = ''; + $ctx->localPartQuoted = true; } $isUtf8 = $this->isUtf8Char($curChar); @@ -975,45 +975,45 @@ private function handleAddressNonAtext(ParseContext $ctx, string $curChar): void if ($isUtf8 && $this->options->allowUtf8LocalPart) { // UTF-8 character allowed if ($ctx->subState === self::STATE_START) { - $ctx->address_temp .= $curChar; + $ctx->addressTemp .= $curChar; } else { - $ctx->local_part_parsed .= $curChar; + $ctx->localPartParsed .= $curChar; } } elseif ($isUtf8) { // UTF-8 present but not allowed by rules — collect and reject in validateLocalPart() if ($ctx->subState === self::STATE_START) { - $ctx->address_temp .= $curChar; + $ctx->addressTemp .= $curChar; // ??= preserves the first invalid character seen; later chars must not overwrite it - $ctx->special_char_in_substate ??= $curChar; + $ctx->specialCharInSubstate ??= $curChar; } else { $ctx->invalid = true; - $ctx->invalid_reason = "Invalid character found in email address local part: '{$curChar}'"; - $ctx->invalid_reason_code = Err::InvalidCharacterInLocalPart; + $ctx->invalidReason = "Invalid character found in email address local part: '{$curChar}'"; + $ctx->invalidReasonCode = Err::InvalidCharacterInLocalPart; } } else { // Non-UTF-8, non-atext character if ($ctx->subState === self::STATE_START) { // ??= preserves the first invalid character seen; later chars must not overwrite it - $ctx->special_char_in_substate ??= $curChar; - $ctx->address_temp .= $curChar; + $ctx->specialCharInSubstate ??= $curChar; + $ctx->addressTemp .= $curChar; } else { $ctx->invalid = true; - $ctx->invalid_reason = "Invalid character found in email address local part: '{$curChar}'"; - $ctx->invalid_reason_code = Err::InvalidCharacterInLocalPart; + $ctx->invalidReason = "Invalid character found in email address local part: '{$curChar}'"; + $ctx->invalidReasonCode = Err::InvalidCharacterInLocalPart; } } } elseif (self::STATE_NAME === $ctx->subState) { - if ($ctx->quote_temp) { - $ctx->name_parsed .= $ctx->quote_temp; - $ctx->quote_temp = ''; - $ctx->name_quoted = true; + if ($ctx->quoteTemp) { + $ctx->nameParsed .= $ctx->quoteTemp; + $ctx->quoteTemp = ''; + $ctx->nameQuoted = true; } - $ctx->special_char_in_substate = $curChar; - $ctx->name_parsed .= $curChar; + $ctx->specialCharInSubstate = $curChar; + $ctx->nameParsed .= $curChar; } else { $ctx->invalid = true; - $ctx->invalid_reason = "Invalid character found in email address (please put in quotes if needed): '{$curChar}'"; - $ctx->invalid_reason_code = Err::InvalidCharacterInAddress; + $ctx->invalidReason = "Invalid character found in email address (please put in quotes if needed): '{$curChar}'"; + $ctx->invalidReasonCode = Err::InvalidCharacterInAddress; } } @@ -1022,7 +1022,7 @@ private function handleAddressNonAtext(ParseContext $ctx, string $curChar): void */ private function handleStateSquareBracket(ParseContext $ctx, string $curChar): void { - $ctx->original_address .= $curChar; + $ctx->originalAddress .= $curChar; if (']' == $curChar) { $ctx->subState = self::STATE_AFTER_DOMAIN; $ctx->state = self::STATE_ADDRESS; @@ -1038,20 +1038,20 @@ private function handleStateSquareBracket(ParseContext $ctx, string $curChar): v */ private function handleStateObsRoute(ParseContext $ctx, string $curChar): void { - $ctx->original_address .= $curChar; + $ctx->originalAddress .= $curChar; if (':' == $curChar) { $ctx->state = self::STATE_ADDRESS; $ctx->subState = self::STATE_LOCAL_PART; } elseif ('>' == $curChar) { // `<@host>` without a colon — incomplete obs-route. $ctx->invalid = true; - $ctx->invalid_reason = 'Incomplete obs-route: missing colon before closing angle-bracket'; - $ctx->invalid_reason_code = Err::IncompleteAddress; - $ctx->in_angle_addr = false; + $ctx->invalidReason = 'Incomplete obs-route: missing colon before closing angle-bracket'; + $ctx->invalidReasonCode = Err::IncompleteAddress; + $ctx->inAngleAddr = false; $ctx->state = self::STATE_ADDRESS; $ctx->subState = self::STATE_AFTER_DOMAIN; } else { - $ctx->obs_route .= $curChar; + $ctx->obsRoute .= $curChar; } } @@ -1062,7 +1062,7 @@ private function handleStateObsRoute(ParseContext $ctx, string $curChar): void */ private function handleStateQuote(ParseContext $ctx, string $curChar, int $i): void { - $ctx->original_address .= $curChar; + $ctx->originalAddress .= $curChar; if ('"' == $curChar) { // RFC 5322 §3.2.4 / RFC 5321 §4.1.2: detect escaped quote by counting // consecutive backslashes immediately before this position. An odd count @@ -1078,7 +1078,7 @@ private function handleStateQuote(ParseContext $ctx, string $curChar, int $i): v } if ($backslashCount && 1 == $backslashCount % 2) { // Odd number of backslashes = this quote is escaped - $ctx->quote_temp .= $curChar; + $ctx->quoteTemp .= $curChar; } else { // Even backslashes (or zero) = this is the real closing quote. // Record that a quote was seen so an *empty* quoted local-part @@ -1087,17 +1087,17 @@ private function handleStateQuote(ParseContext $ctx, string $curChar, int $i): v // display-name quote self-corrects: the real local-part resets // this flag from address_temp_quoted when '@' is reached. $ctx->state = self::STATE_ADDRESS; - $ctx->local_part_quoted = true; - $ctx->after_closing_quote = true; + $ctx->localPartQuoted = true; + $ctx->afterClosingQuote = true; } } elseif ($this->options->rejectC0Controls && 1 === strlen($curChar) && "\t" !== $curChar && (ord($curChar) < 32 || "\x7f" === $curChar)) { // qtext (RFC 5322 §3.2.4) excludes C0 controls; a bare CR or LF // inside a quoted-string is not valid (only a CRLF fold with WSP is). $ctx->invalid = true; - $ctx->invalid_reason = 'Control character in quoted string'; - $ctx->invalid_reason_code = Err::InvalidCharInQuotedString; + $ctx->invalidReason = 'Control character in quoted string'; + $ctx->invalidReasonCode = Err::InvalidCharInQuotedString; } else { - $ctx->quote_temp .= $curChar; + $ctx->quoteTemp .= $curChar; } } @@ -1107,22 +1107,22 @@ private function handleStateQuote(ParseContext $ctx, string $curChar, int $i): v */ private function handleStateComment(ParseContext $ctx, string $curChar): void { - $ctx->original_address .= $curChar; - if ($ctx->comment_escaped) { + $ctx->originalAddress .= $curChar; + if ($ctx->commentEscaped) { // Target of a quoted-pair — literal, never structural. - $ctx->comment_escaped = false; - $ctx->comment_temp .= $curChar; + $ctx->commentEscaped = false; + $ctx->commentTemp .= $curChar; } elseif ('\\' == $curChar) { // RFC 5322 §3.2.1: backslash starts a quoted-pair; the next // character is escaped (so "\)" does not close the comment). - $ctx->comment_escaped = true; + $ctx->commentEscaped = true; } elseif (')' == $curChar) { --$ctx->commentNestLevel; if ($ctx->commentNestLevel <= 0) { // End of comment - save it - if ($ctx->comment_temp) { - $ctx->comments[] = $ctx->comment_temp; - $ctx->comment_temp = ''; + if ($ctx->commentTemp) { + $ctx->comments[] = $ctx->commentTemp; + $ctx->commentTemp = ''; } $ctx->state = self::STATE_ADDRESS; // Flag a comment that closed mid-word in the local part (before @@ -1131,34 +1131,34 @@ private function handleStateComment(ParseContext $ctx, string $curChar): void // preceding quoted-string (local_part_quoted) — "x"(c)y is as // invalid as x(c)y. Domain and display-name comments are excluded. if ((self::STATE_LOCAL_PART === $ctx->subState || self::STATE_START === $ctx->subState) - && ('' !== $ctx->address_temp || '' !== $ctx->local_part_parsed || $ctx->local_part_quoted)) { - $ctx->comment_after_local_atext = true; + && ('' !== $ctx->addressTemp || '' !== $ctx->localPartParsed || $ctx->localPartQuoted)) { + $ctx->commentAfterLocalAtext = true; } } else { // Nested comment closing parenthesis - $ctx->comment_temp .= $curChar; + $ctx->commentTemp .= $curChar; } } elseif ('(' == $curChar) { ++$ctx->commentNestLevel; if ($ctx->commentNestLevel > 1) { // Nested comment opening parenthesis - $ctx->comment_temp .= $curChar; + $ctx->commentTemp .= $curChar; } } elseif ($this->options->rejectC0Controls && 1 === strlen($curChar) && "\t" !== $curChar && (ord($curChar) < 32 || "\x7f" === $curChar)) { // ctext (RFC 5322 §3.2.3) excludes C0 controls; a bare CR or LF // inside a comment is not part of valid folding. $ctx->invalid = true; - $ctx->invalid_reason = 'Control character in comment'; - $ctx->invalid_reason_code = Err::ControlCharInComment; + $ctx->invalidReason = 'Control character in comment'; + $ctx->invalidReasonCode = Err::ControlCharInComment; } elseif ($this->options->rejectC1Controls && preg_match('/[\x{0080}-\x{009F}]/u', $curChar)) { // RFC 6532 §3.1: C1 controls (2-byte UTF-8) are prohibited in // internationalized content, comments included. $ctx->invalid = true; - $ctx->invalid_reason = 'Control character in comment'; - $ctx->invalid_reason_code = Err::ControlCharInComment; + $ctx->invalidReason = 'Control character in comment'; + $ctx->invalidReasonCode = Err::ControlCharInComment; } else { // Regular comment character - $ctx->comment_temp .= $curChar; + $ctx->commentTemp .= $curChar; } } @@ -1171,19 +1171,19 @@ private function handleStateComment(ParseContext $ctx, string $curChar): void */ private function handleQuote(ParseContext $ctx): void { - if ($ctx->quote_temp) { - $ctx->name_parsed .= $ctx->quote_temp; - $ctx->name_quoted = true; - $ctx->quote_temp = ''; - } elseif ($ctx->address_temp) { - $ctx->name_parsed .= $ctx->address_temp; - $ctx->name_quoted = $ctx->address_temp_quoted; - $ctx->address_temp_quoted = false; - $ctx->address_temp = ''; - if ($ctx->address_temp_period > 0) { + if ($ctx->quoteTemp) { + $ctx->nameParsed .= $ctx->quoteTemp; + $ctx->nameQuoted = true; + $ctx->quoteTemp = ''; + } elseif ($ctx->addressTemp) { + $ctx->nameParsed .= $ctx->addressTemp; + $ctx->nameQuoted = $ctx->addressTempQuoted; + $ctx->addressTempQuoted = false; + $ctx->addressTemp = ''; + if ($ctx->addressTempPeriod > 0) { $ctx->invalid = true; - $ctx->invalid_reason = 'Periods within the display name of an email address must appear in quotes, such as "John Q. Public" according to RFC 5322'; - $ctx->invalid_reason_code = Err::UnquotedPeriodInDisplayName; + $ctx->invalidReason = 'Periods within the display name of an email address must appear in quotes, such as "John Q. Public" according to RFC 5322'; + $ctx->invalidReasonCode = Err::UnquotedPeriodInDisplayName; } } } @@ -1211,41 +1211,41 @@ private function addAddress( $ctx->ip = $ctx->domain; $ctx->domain = ''; } - if ($ctx->address_temp || $ctx->quote_temp) { + if ($ctx->addressTemp || $ctx->quoteTemp) { $ctx->invalid = true; - $ctx->invalid_reason = 'Incomplete address'; - $ctx->invalid_reason_code = Err::IncompleteAddress; - $this->log('error', "Email\\Parse->addAddress - corruption during parsing - leftovers:\n\$i: {$i}\n\$ctx->address_temp : {$ctx->address_temp}\n\$ctx->quote_temp: {$ctx->quote_temp}\n"); + $ctx->invalidReason = 'Incomplete address'; + $ctx->invalidReasonCode = Err::IncompleteAddress; + $this->log('error', "Email\\Parse->addAddress - corruption during parsing - leftovers:\n\$i: {$i}\n\$ctx->addressTemp : {$ctx->addressTemp}\n\$ctx->quoteTemp: {$ctx->quoteTemp}\n"); } elseif ($ctx->ip && $ctx->domain) { // Error - this should never occur $ctx->invalid = true; - $ctx->invalid_reason = 'Confusion during parsing'; - $ctx->invalid_reason_code = Err::ParserConfusion; - $this->log('error', "Email\\Parse->addAddress - both an IP address '{$ctx->ip}' and a domain '{$ctx->domain}' found for the email address '{$ctx->original_address}'\n"); + $ctx->invalidReason = 'Confusion during parsing'; + $ctx->invalidReasonCode = Err::ParserConfusion; + $this->log('error', "Email\\Parse->addAddress - both an IP address '{$ctx->ip}' and a domain '{$ctx->domain}' found for the email address '{$ctx->originalAddress}'\n"); } elseif ($ctx->ip) { if (filter_var($ctx->ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) { if ($this->options->validateIpGlobalRange && !$this->validateIpGlobalRange($ctx->ip, FILTER_FLAG_IPV4)) { $ctx->invalid = true; - $ctx->invalid_reason = 'IP address invalid: \'' . $ctx->ip . '\' does not appear to be a valid IP address in the global range'; - $ctx->invalid_reason_code = Err::IpNotInGlobalRange; + $ctx->invalidReason = 'IP address invalid: \'' . $ctx->ip . '\' does not appear to be a valid IP address in the global range'; + $ctx->invalidReasonCode = Err::IpNotInGlobalRange; } } elseif (str_starts_with($ctx->ip, 'IPv6:')) { $tempIp = str_replace('IPv6:', '', $ctx->ip); if (filter_var($tempIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) { if ($this->options->validateIpGlobalRange && !$this->validateIpGlobalRange($tempIp, FILTER_FLAG_IPV6)) { $ctx->invalid = true; - $ctx->invalid_reason = 'IP address invalid: \'' . $ctx->ip . '\' does not appear to be a valid IPv6 address in the global range'; - $ctx->invalid_reason_code = Err::Ipv6NotInGlobalRange; + $ctx->invalidReason = 'IP address invalid: \'' . $ctx->ip . '\' does not appear to be a valid IPv6 address in the global range'; + $ctx->invalidReasonCode = Err::Ipv6NotInGlobalRange; } } else { $ctx->invalid = true; - $ctx->invalid_reason = 'IP address invalid: \'' . $ctx->ip . '\' does not appear to be a valid IP address'; - $ctx->invalid_reason_code = Err::InvalidIpAddress; + $ctx->invalidReason = 'IP address invalid: \'' . $ctx->ip . '\' does not appear to be a valid IP address'; + $ctx->invalidReasonCode = Err::InvalidIpAddress; } } else { $ctx->invalid = true; - $ctx->invalid_reason = 'IP address invalid: \'' . $ctx->ip . '\' does not appear to be a valid IP address'; - $ctx->invalid_reason_code = Err::InvalidIpAddress; + $ctx->invalidReason = 'IP address invalid: \'' . $ctx->ip . '\' does not appear to be a valid IP address'; + $ctx->invalidReasonCode = Err::InvalidIpAddress; } } elseif ($ctx->domain) { // Optional FQDN root-label dot (RFC 5321 §2.3.5 allows "example.com."). @@ -1253,8 +1253,8 @@ private function addAddress( if (str_ends_with($ctx->domain, '.')) { if ($this->options->rejectTrailingDot) { $ctx->invalid = true; - $ctx->invalid_reason = 'Domain must not end with a trailing dot'; - $ctx->invalid_reason_code = Err::TrailingDotNotAllowed; + $ctx->invalidReason = 'Domain must not end with a trailing dot'; + $ctx->invalidReasonCode = Err::TrailingDotNotAllowed; } else { $ctx->domain = substr($ctx->domain, 0, -1); } @@ -1273,34 +1273,34 @@ private function addAddress( $domainAscii = $this->normalizeDomainAscii($ctx->domain); if ($domainAscii === null) { $ctx->invalid = true; - $ctx->invalid_reason = "Can't convert domain {$ctx->domain} to punycode"; - $ctx->invalid_reason_code = Err::PunycodeConversionFailed; + $ctx->invalidReason = "Can't convert domain {$ctx->domain} to punycode"; + $ctx->invalidReasonCode = Err::PunycodeConversionFailed; } else { if ($domainAscii !== $ctx->domain) { - $ctx->domain_ascii = $domainAscii; + $ctx->domainAscii = $domainAscii; } $result = $this->validateDomainName($domainAscii); if (!$result['valid']) { $ctx->invalid = true; - $ctx->invalid_reason = isset($result['reason']) ? 'Domain invalid: '.$result['reason'] : 'Domain invalid for some unknown reason'; - $ctx->invalid_reason_code = $result['code'] ?? Err::DomainInvalid; + $ctx->invalidReason = isset($result['reason']) ? 'Domain invalid: '.$result['reason'] : 'Domain invalid for some unknown reason'; + $ctx->invalidReasonCode = $result['code'] ?? Err::DomainInvalid; } } } } // Prepare some of the fields needed - $ctx->name_parsed = rtrim($ctx->name_parsed); - $ctx->original_address = rtrim($ctx->original_address); - $name = $ctx->name_quoted ? "\"{$ctx->name_parsed}\"" : $ctx->name_parsed; - $localPart = $ctx->local_part_quoted ? "\"{$ctx->local_part_parsed}\"" : $ctx->local_part_parsed; + $ctx->nameParsed = rtrim($ctx->nameParsed); + $ctx->originalAddress = rtrim($ctx->originalAddress); + $name = $ctx->nameQuoted ? "\"{$ctx->nameParsed}\"" : $ctx->nameParsed; + $localPart = $ctx->localPartQuoted ? "\"{$ctx->localPartParsed}\"" : $ctx->localPartParsed; $domainPart = $ctx->ip ? '['.$ctx->ip.']' : $ctx->domain; if (!$ctx->invalid) { if (0 == strlen($domainPart)) { $ctx->invalid = true; - $ctx->invalid_reason = 'Email address needs a domain after the \'@\''; - $ctx->invalid_reason_code = Err::MissingDomain; + $ctx->invalidReason = 'Email address needs a domain after the \'@\''; + $ctx->invalidReasonCode = Err::MissingDomain; } } @@ -1312,13 +1312,13 @@ private function addAddress( // rejection of non-atext bytes such as stray UTF-8 in an unquoted name. if (!$ctx->invalid && $this->options->validateDisplayNamePhrase - && !$ctx->name_quoted - && $ctx->name_parsed !== '' - && !preg_match('#^[A-Za-z0-9!\#$%&\'*+\-/=?^_`{|}~ \t]+$#', $ctx->name_parsed) + && !$ctx->nameQuoted + && $ctx->nameParsed !== '' + && !preg_match('#^[A-Za-z0-9!\#$%&\'*+\-/=?^_`{|}~ \t]+$#', $ctx->nameParsed) ) { $ctx->invalid = true; - $ctx->invalid_reason = "Display name '{$ctx->name_parsed}' must be a quoted-string or atext-only phrase per RFC 5322 §3.2.5"; - $ctx->invalid_reason_code = Err::InvalidDisplayNamePhrase; + $ctx->invalidReason = "Display name '{$ctx->nameParsed}' must be a quoted-string or atext-only phrase per RFC 5322 §3.2.5"; + $ctx->invalidReasonCode = Err::InvalidDisplayNamePhrase; } // Unified local-part validation. @@ -1326,14 +1326,14 @@ private function addAddress( $result = $this->validateLocalPart($ctx); if (!$result['valid']) { $ctx->invalid = true; - $ctx->invalid_reason = $result['reason']; - $ctx->invalid_reason_code = $result['code'] ?? null; + $ctx->invalidReason = $result['reason']; + $ctx->invalidReasonCode = $result['code'] ?? null; } elseif ($result['normalized'] !== null) { // Apply NFC normalization result to the parsed local-part and re-derive display form - $ctx->local_part_parsed = $result['normalized']; - $localPart = $ctx->local_part_quoted - ? "\"{$ctx->local_part_parsed}\"" - : $ctx->local_part_parsed; + $ctx->localPartParsed = $result['normalized']; + $localPart = $ctx->localPartQuoted + ? "\"{$ctx->localPartParsed}\"" + : $ctx->localPartParsed; } // Optional caller-supplied local-part normalizer — invoked after structural @@ -1345,12 +1345,12 @@ private function addAddress( // still preserves the verbatim input. if (!$ctx->invalid && $this->options->localPartNormalizer !== null) { $normalizer = $this->options->localPartNormalizer; - $normalized = $normalizer($ctx->local_part_parsed, $ctx->domain); - if ($normalized !== $ctx->local_part_parsed) { - $ctx->local_part_parsed = $normalized; - $localPart = $ctx->local_part_quoted - ? "\"{$ctx->local_part_parsed}\"" - : $ctx->local_part_parsed; + $normalized = $normalizer($ctx->localPartParsed, $ctx->domain); + if ($normalized !== $ctx->localPartParsed) { + $ctx->localPartParsed = $normalized; + $localPart = $ctx->localPartQuoted + ? "\"{$ctx->localPartParsed}\"" + : $ctx->localPartParsed; } } } @@ -1360,8 +1360,8 @@ private function addAddress( $dotPos = strpos($ctx->domain, '.'); if ($dotPos === false || $dotPos === 0 || $dotPos === strlen($ctx->domain) - 1) { $ctx->invalid = true; - $ctx->invalid_reason = 'Domain must be a fully-qualified domain name'; - $ctx->invalid_reason_code = Err::FqdnRequired; + $ctx->invalidReason = 'Domain must be a fully-qualified domain name'; + $ctx->invalidReasonCode = Err::FqdnRequired; } } @@ -1370,38 +1370,38 @@ private function addAddress( if (!$ctx->invalid && $this->options->enforceLengthLimits) { $limits = $this->options->getLengthLimits(); // RFC 5321 §4.5.3.1.1: local-part max 64 octets (wire form includes DQUOTE for quoted strings) - $localPartWireLen = $ctx->local_part_quoted - ? strlen($ctx->local_part_parsed) + 2 - : strlen($ctx->local_part_parsed); + $localPartWireLen = $ctx->localPartQuoted + ? strlen($ctx->localPartParsed) + 2 + : strlen($ctx->localPartParsed); if ($localPartWireLen > $limits->maxLocalPartLength) { $ctx->invalid = true; - $ctx->invalid_reason = "Email address before the '@' can not be greater than {$limits->maxLocalPartLength} octets per RFC 5321"; - $ctx->invalid_reason_code = Err::LocalPartTooLong; + $ctx->invalidReason = "Email address before the '@' can not be greater than {$limits->maxLocalPartLength} octets per RFC 5321"; + $ctx->invalidReasonCode = Err::LocalPartTooLong; } elseif (($localPartWireLen + 1 + strlen($domainPart)) > $limits->maxTotalLength) { $ctx->invalid = true; - $ctx->invalid_reason = "Email addresses can not be greater than {$limits->maxTotalLength} octets per RFC 3696 EID 1690"; - $ctx->invalid_reason_code = Err::TotalLengthExceeded; + $ctx->invalidReason = "Email addresses can not be greater than {$limits->maxTotalLength} octets per RFC 3696 EID 1690"; + $ctx->invalidReasonCode = Err::TotalLengthExceeded; } } // Build the email address hash $emailAddrDef = ['address' => '', 'simple_address' => '', - 'original_address' => rtrim($ctx->original_address), + 'original_address' => rtrim($ctx->originalAddress), 'name' => $name, - 'name_parsed' => $ctx->name_parsed, + 'name_parsed' => $ctx->nameParsed, 'local_part' => $localPart, - 'local_part_parsed' => $ctx->local_part_parsed, + 'local_part_parsed' => $ctx->localPartParsed, 'domain_part' => $domainPart, 'domain' => $ctx->domain, - 'domain_ascii' => $this->options->includeDomainAscii ? ($ctx->domain_ascii ?? null) : null, + 'domain_ascii' => $this->options->includeDomainAscii ? ($ctx->domainAscii ?? null) : null, 'ip' => $ctx->ip, 'invalid' => $ctx->invalid, - 'invalid_reason' => $ctx->invalid_reason, - 'invalid_reason_code' => $ctx->invalid_reason_code, + 'invalid_reason' => $ctx->invalidReason, + 'invalid_reason_code' => $ctx->invalidReasonCode, 'comments' => $ctx->comments, - 'obs_route' => $ctx->obs_route !== '' ? $ctx->obs_route : null, + 'obs_route' => $ctx->obsRoute !== '' ? $ctx->obsRoute : null, 'domain_is_suspicious' => $this->isDomainConfusable($ctx->domain), ]; // Build the proper address by hand (has comments stripped out and should have quotes in the proper places) @@ -1454,8 +1454,8 @@ private function isDomainConfusable(string $domain): bool private function validateLocalPart(ParseContext $ctx): array { $opts = $this->options; - $localPart = $ctx->local_part_parsed; - $quoted = $ctx->local_part_quoted; + $localPart = $ctx->localPartParsed; + $quoted = $ctx->localPartQuoted; // RFC 6531 §3.3 / RFC 6532 §3.2: gate UTF-8 presence before other checks // (allowUtf8LocalPart is false in rfc5321() and rfc5322() presets) diff --git a/src/ParseContext.php b/src/ParseContext.php index 5565ada..41a85ed 100644 --- a/src/ParseContext.php +++ b/src/ParseContext.php @@ -14,8 +14,7 @@ * localPartNormalizer closure may call back into parse() mid-parse without * clobbering the outer parse's state. * - * The accumulator property names mirror the historical $emailAddress array keys - * so they thread through the validation helpers unchanged; the public output + * The accumulator uses camelCase property names; the public snake_case output * array shape is built separately in {@see Parse::addAddress()} and is * unaffected by this object. * @@ -70,62 +69,62 @@ final class ParseContext // --- Accumulator fields (reset per address via resetAddress()). --- /** Raw address as given, comments included. */ - public string $original_address = ''; + public string $originalAddress = ''; /** Display name without quotes. */ - public string $name_parsed = ''; + public string $nameParsed = ''; /** Local-part without quotes. */ - public string $local_part_parsed = ''; + public string $localPartParsed = ''; /** Domain after '@' (may be Unicode/U-label). */ public string $domain = ''; /** Punycode A-label domain, populated when it differs from $domain. */ - public ?string $domain_ascii = null; + public ?string $domainAscii = null; /** IP address if a domain-literal was used. */ public string $ip = ''; public bool $invalid = false; - public ?string $invalid_reason = null; + public ?string $invalidReason = null; - public ?ParseErrorCode $invalid_reason_code = null; + public ?ParseErrorCode $invalidReasonCode = null; - public bool $local_part_quoted = false; + public bool $localPartQuoted = false; - public bool $name_quoted = false; + public bool $nameQuoted = false; - public bool $address_temp_quoted = false; + public bool $addressTempQuoted = false; /** * True for exactly the character after a closing quote, so atext / a second * quote directly abutting a quoted-string can be rejected. */ - public bool $after_closing_quote = false; + public bool $afterClosingQuote = false; - public string $quote_temp = ''; + public string $quoteTemp = ''; - public string $address_temp = ''; + public string $addressTemp = ''; - public int $address_temp_period = 0; + public int $addressTempPeriod = 0; - public ?string $special_char_in_substate = null; + public ?string $specialCharInSubstate = null; - public string $comment_temp = ''; + public string $commentTemp = ''; /** * True for the character following an unescaped backslash inside a comment * (RFC 5322 §3.2.1 quoted-pair: "\)" and "\(" are literal, not structural). */ - public bool $comment_escaped = false; + public bool $commentEscaped = false; /** * True just after a comment closes mid-atom in the local part (atext already * accumulated), so the very next character can be inspected. */ - public bool $comment_after_local_atext = false; + public bool $commentAfterLocalAtext = false; /** * Set when atext resumes the atom after such a comment. Whether that is an @@ -133,7 +132,7 @@ final class ParseContext * addr-spec (resolved at '@' → reject, RFC 5322 §3.2.3) or a display-name * phrase where "word CFWS word" is legal (resolved at '<' → clear). */ - public bool $local_atom_split_by_comment = false; + public bool $localAtomSplitByComment = false; /** @var array Extracted RFC 5322 comments. */ public array $comments = []; @@ -142,14 +141,14 @@ final class ParseContext * True while the parser is inside angle-addr (between `<` and `>`). * Used to gate obs-route detection per RFC 5322 §4.4. */ - public bool $in_angle_addr = false; + public bool $inAngleAddr = false; /** * Accumulates the obs-route prefix (everything between `<` and the * terminating `:`) when ParseOptions::$allowObsRoute is true. * Empty string when no obs-route was seen. */ - public string $obs_route = ''; + public string $obsRoute = ''; /** * @param int $state Initial parser state (a Parse::STATE_* value). @@ -181,29 +180,29 @@ public function resetAddress(int $state, int $subState): void $this->subState = $subState; $this->commentNestLevel = 0; - $this->original_address = ''; - $this->name_parsed = ''; - $this->local_part_parsed = ''; + $this->originalAddress = ''; + $this->nameParsed = ''; + $this->localPartParsed = ''; $this->domain = ''; - $this->domain_ascii = null; + $this->domainAscii = null; $this->ip = ''; $this->invalid = false; - $this->invalid_reason = null; - $this->invalid_reason_code = null; - $this->local_part_quoted = false; - $this->name_quoted = false; - $this->address_temp_quoted = false; - $this->after_closing_quote = false; - $this->quote_temp = ''; - $this->address_temp = ''; - $this->address_temp_period = 0; - $this->special_char_in_substate = null; - $this->comment_temp = ''; - $this->comment_escaped = false; - $this->comment_after_local_atext = false; - $this->local_atom_split_by_comment = false; + $this->invalidReason = null; + $this->invalidReasonCode = null; + $this->localPartQuoted = false; + $this->nameQuoted = false; + $this->addressTempQuoted = false; + $this->afterClosingQuote = false; + $this->quoteTemp = ''; + $this->addressTemp = ''; + $this->addressTempPeriod = 0; + $this->specialCharInSubstate = null; + $this->commentTemp = ''; + $this->commentEscaped = false; + $this->commentAfterLocalAtext = false; + $this->localAtomSplitByComment = false; $this->comments = []; - $this->in_angle_addr = false; - $this->obs_route = ''; + $this->inAngleAddr = false; + $this->obsRoute = ''; } } From a0f9bf7f4d9dc9cc061b1b261fab78e42cda5dda Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Wed, 26 Aug 2026 00:11:45 -0700 Subject: [PATCH 05/23] 4.0: make ParseContext snapshot + config readonly (immutable per parse) Encodes the context's three concerns structurally: the input snapshot (chars/len/multiple/emails) and hoisted config (separators/bannedChars/ useWhitespaceAsSeparator/allowedWhitespace) are now public readonly constructor-promoted properties, so a state handler can no longer mutate config; only the per-address accumulator stays mutable. parseInternal() now builds chars/len/config before constructing the context and passes them in (they were previously assigned after `new`). $chars/$len are still kept as loop locals for the hot counter. 109 tests / 7186 assertions, PHPStan L8, Psalm, CS all green. --- ROADMAP.md | 2 +- src/Parse.php | 38 +++++++++++++-------------- src/ParseContext.php | 62 +++++++++++++++++++++----------------------- 3 files changed, 49 insertions(+), 53 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index fb22beb..476eddf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -96,7 +96,7 @@ Continuous work, not tied to a specific release. - [ ] **`parse()` refactor & modernization follow-ups** (from review; non-blocking, each behavior-preserving and test-gated): - [x] Renamed `ParseContext`'s accumulator fields `snake_case` → `camelCase` to match the codebase. Output-array keys stay `snake_case` (public API, string literals in `addAddress()`); only the internal properties changed. - - [ ] **Encode `ParseContext`'s three concerns structurally.** The immutable input snapshot (`chars`/`len`/`emails`), the read-only hoisted config (`separators`, `bannedChars`, …), and the mutable per-address accumulator are all plain public fields today, so nothing stops a handler writing config. Promote the snapshot + config to `readonly` (constructor-promoted) so only the accumulator stays mutable — the clearest SOTA/correctness win, but it needs `parse()`'s construction reworked (the snapshot is currently assigned after `new`). + - [x] **Encoded `ParseContext`'s three concerns structurally.** The input snapshot (`chars`/`len`/`emails`) and hoisted config (`separators`, `bannedChars`, …) are now `public readonly` constructor-promoted properties, so only the per-address accumulator stays mutable — a handler can no longer write config. `parseInternal()`'s setup was reordered to build the config before constructing the context. - [ ] **Consider a `ParserState: int` backed enum** in place of the 13 `STATE_*` int constants. Gives type-safety on `$ctx->state`/`$subState` and would likely retire the Psalm state-narrowing baseline entries. Gate on a benchmark: the dispatch is a hot loop, so measure enum-vs-int comparison/array-key overhead before committing (the no-regression constraint still applies). - [ ] Drop the `chars`/`len` double source of truth (loop locals vs context properties — kept for hot-loop locality; measure before changing). - [ ] Decompose the two remaining large methods — `handleStateAddress` (~209 lines; CFWS/`@`/non-atext already peeled off, the rest is inherent to the addr-spec sub-machine) and `addAddress` (~221 lines, pre-existing; splits into IP-literal detection, validation, and output-array assembly). Both diminishing-returns polish. diff --git a/src/Parse.php b/src/Parse.php index 533bf21..5b488fc 100644 --- a/src/Parse.php +++ b/src/Parse.php @@ -293,14 +293,6 @@ public function parseStream(iterable $input, string $encoding = 'UTF-8'): \Gener private function parseInternal(string $emails, bool $multiple, string $encoding): array { $emailAddresses = []; - - // Per-parse accumulator. A fresh instance (never an instance property) - // keeps parse() reentrant across a localPartNormalizer callback. The - // constructor requires the initial state (STATE_TRIM) and sub-state - // (STATE_START, for when we reach the xyz@somewhere.com address itself), - // so the context is fully initialized before its first use. - $ctx = new ParseContext(self::STATE_TRIM, self::STATE_START); - $success = true; $reason = null; @@ -322,17 +314,25 @@ private function parseInternal(string $emails, bool $multiple, string $encoding) unset($allowedWhitespace["\r"], $allowedWhitespace["\n"]); } - // Publish the input snapshot and hoisted config onto the context so the - // per-state handlers can read them without long parameter lists. $chars - // and $len are also kept as locals below for the tight loop counter. - $ctx->chars = $chars; - $ctx->len = $len; - $ctx->multiple = $multiple; - $ctx->emails = $emails; - $ctx->separators = $this->options->getSeparators(); - $ctx->bannedChars = $this->options->getBannedChars(); - $ctx->useWhitespaceAsSeparator = $this->options->getUseWhitespaceAsSeparator(); - $ctx->allowedWhitespace = $allowedWhitespace; + // Per-parse accumulator. A fresh instance (never an instance property) + // keeps parse() reentrant across a localPartNormalizer callback. The + // constructor takes the initial state (STATE_TRIM) and sub-state + // (STATE_START) plus the immutable input snapshot + hoisted config, which + // it exposes as readonly properties — so no handler can mutate config, and + // the context is fully initialized before first use. $chars/$len are also + // kept as locals below for the tight loop counter. + $ctx = new ParseContext( + self::STATE_TRIM, + self::STATE_START, + $chars, + $len, + $multiple, + $emails, + $this->options->getSeparators(), + $this->options->getBannedChars(), + $this->options->getUseWhitespaceAsSeparator(), + $allowedWhitespace, + ); $curChar = null; for ($i = 0; $i < $len; ++$i) { diff --git a/src/ParseContext.php b/src/ParseContext.php index 41a85ed..ad9d6f8 100644 --- a/src/ParseContext.php +++ b/src/ParseContext.php @@ -23,32 +23,10 @@ */ final class ParseContext { - // --- Per-parse input snapshot and hoisted config (set once in parse(), - // never reset between addresses). --- - - /** @var array The input split into characters (see parse()). */ - public array $chars = []; - - /** Number of characters in $chars. */ - public int $len = 0; - - /** Whether multiple addresses are being parsed. */ - public bool $multiple = true; - - /** The original input string, retained for diagnostic logging. */ - public string $emails = ''; - - /** @var array Separator characters, as a lookup map. */ - public array $separators = []; - - /** @var array Banned characters, as a lookup map. */ - public array $bannedChars = []; - - /** Whether whitespace acts as an address separator. */ - public bool $useWhitespaceAsSeparator = false; - - /** @var array Insignificant (foldable/trimmable) whitespace, as a lookup map. */ - public array $allowedWhitespace = []; + // The per-parse input snapshot and hoisted config (chars, len, multiple, + // emails, separators, bannedChars, useWhitespaceAsSeparator, + // allowedWhitespace) are immutable for the whole parse — they are declared as + // `public readonly` constructor-promoted properties (see __construct below). // --- Loop control state (state/subState reset per address by parse()). --- @@ -151,14 +129,32 @@ final class ParseContext public string $obsRoute = ''; /** - * @param int $state Initial parser state (a Parse::STATE_* value). - * @param int $subState Initial addr-spec sub-state (a Parse::STATE_* value). + * @param int $state Initial parser state (a Parse::STATE_* value). + * @param int $subState Initial addr-spec sub-state (a Parse::STATE_* value). + * @param array $chars Input split into characters. + * @param int $len Number of characters in $chars. + * @param bool $multiple Whether multiple addresses are being parsed. + * @param string $emails Original input string (retained for diagnostic logging). + * @param array $separators Separator characters, as a lookup map. + * @param array $bannedChars Banned characters, as a lookup map. + * @param bool $useWhitespaceAsSeparator Whether whitespace acts as an address separator. + * @param array $allowedWhitespace Insignificant (foldable/trimmable) whitespace, as a lookup map. */ - public function __construct(int $state, int $subState) - { - // Requiring the initial states makes an un-initialized context - // unrepresentable: every instance is reset before its first use, so no - // caller can start parsing from the misleading zero-value field defaults. + public function __construct( + int $state, + int $subState, + public readonly array $chars, + public readonly int $len, + public readonly bool $multiple, + public readonly string $emails, + public readonly array $separators, + public readonly array $bannedChars, + public readonly bool $useWhitespaceAsSeparator, + public readonly array $allowedWhitespace, + ) { + // Requiring the initial states + immutable snapshot makes an + // un-initialized context unrepresentable: config cannot be mutated by a + // handler, and every instance is reset before its first use. $this->resetAddress($state, $subState); } From 7896f0fb095a7119ebac3e285f27ca2a60366979 Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Thu, 27 Aug 2026 22:25:04 -0700 Subject: [PATCH 06/23] 4.0: replace STATE_* int constants with a ParserState backed enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces src/ParserState.php (backed int enum, values matching the former Parse::STATE_* constants) and types ParseContext::$state/$subState as ParserState. The parser can no longer hold an out-of-range state, and the old "subState 0 default is not a valid start" caveat is gone — an un-initialized enum property is a type error, not a silent wrong value. Mechanical: 75 self::STATE_X -> ParserState::X, the dispatch switch and the in_array() state check now use enum cases, and the one state-interpolating log line uses ->name. Behavior-preserving. Perf: enum === is identity comparison (≈ int), so no hot-loop regression is expected; verified by the CI "Benchmarks (vs base)" job (≤1.5x base). Local phpbench is unreliable here (Xdebug + opcache.enable_cli=0 time it out). 109 tests / 7208 assertions, PHPStan L8, Psalm, CS all green. --- ROADMAP.md | 2 +- src/Parse.php | 175 +++++++++++++++++++------------------------ src/ParseContext.php | 27 +++---- src/ParserState.php | 37 +++++++++ 4 files changed, 127 insertions(+), 114 deletions(-) create mode 100644 src/ParserState.php diff --git a/ROADMAP.md b/ROADMAP.md index 476eddf..7f47e31 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -97,7 +97,7 @@ Continuous work, not tied to a specific release. - [ ] **`parse()` refactor & modernization follow-ups** (from review; non-blocking, each behavior-preserving and test-gated): - [x] Renamed `ParseContext`'s accumulator fields `snake_case` → `camelCase` to match the codebase. Output-array keys stay `snake_case` (public API, string literals in `addAddress()`); only the internal properties changed. - [x] **Encoded `ParseContext`'s three concerns structurally.** The input snapshot (`chars`/`len`/`emails`) and hoisted config (`separators`, `bannedChars`, …) are now `public readonly` constructor-promoted properties, so only the per-address accumulator stays mutable — a handler can no longer write config. `parseInternal()`'s setup was reordered to build the config before constructing the context. - - [ ] **Consider a `ParserState: int` backed enum** in place of the 13 `STATE_*` int constants. Gives type-safety on `$ctx->state`/`$subState` and would likely retire the Psalm state-narrowing baseline entries. Gate on a benchmark: the dispatch is a hot loop, so measure enum-vs-int comparison/array-key overhead before committing (the no-regression constraint still applies). + - [x] **Introduced a `ParserState: int` backed enum** (`src/ParserState.php`) in place of the 13 `Parse::STATE_*` int constants. `ParseContext::$state`/`$subState` are now typed `ParserState`, so the parser can never hold an out-of-range state (this also retires the old "misleading 0 default" caveat). Enum `===` is identity comparison, so no measurable hot-loop cost is expected; the perf no-regression constraint is verified by the CI **Benchmarks (vs base)** job (`bench:compare`, ≤1.5× base) — local benchmarking is unreliable in the dev sandbox (Xdebug + `opcache.enable_cli=0`). - [ ] Drop the `chars`/`len` double source of truth (loop locals vs context properties — kept for hot-loop locality; measure before changing). - [ ] Decompose the two remaining large methods — `handleStateAddress` (~209 lines; CFWS/`@`/non-atext already peeled off, the rest is inherent to the addr-spec sub-machine) and `addAddress` (~221 lines, pre-existing; splits into IP-literal detection, validation, and output-array assembly). Both diminishing-returns polish. - [ ] **Ecosystem bridges:** `mmucklo/email-parse-symfony` (`Constraint` + `ConstraintValidator`), `mmucklo/email-parse-laravel` (validation rule + service provider), PSR-14 `ParsedAddressEvent` for observability. diff --git a/src/Parse.php b/src/Parse.php index 5b488fc..364c346 100644 --- a/src/Parse.php +++ b/src/Parse.php @@ -10,31 +10,12 @@ */ class Parse { - // Constants for the state-machine of the parser - private const STATE_TRIM = 0; - private const STATE_QUOTE = 1; - private const STATE_ADDRESS = 2; - private const STATE_COMMENT = 3; - private const STATE_NAME = 4; - private const STATE_LOCAL_PART = 5; - private const STATE_DOMAIN = 6; - private const STATE_AFTER_DOMAIN = 7; - private const STATE_SQUARE_BRACKET = 8; - private const STATE_SKIP_AHEAD = 9; - private const STATE_END_ADDRESS = 10; - private const STATE_START = 11; + // The state-machine states are the {@see ParserState} enum (formerly + // Parse::STATE_* constants). /** The full set of whitespace characters, as a lookup map (RFC 5234 WSP + CR/LF). */ private const WHITESPACE = [' ' => true, "\t" => true, "\r" => true, "\n" => true]; - /** - * Absorbs the obsolete source-route prefix inside angle-addr - * (RFC 5322 §4.4 obs-route: `"<" obs-domain-list ":" addr-spec ">"`). - * Consumes characters from the leading `@` up to the `:` terminator, - * then resumes normal addr-spec parsing. - */ - private const STATE_OBS_ROUTE = 12; - /** * @var ?Parse */ @@ -322,8 +303,8 @@ private function parseInternal(string $emails, bool $multiple, string $encoding) // the context is fully initialized before first use. $chars/$len are also // kept as locals below for the tight loop counter. $ctx = new ParseContext( - self::STATE_TRIM, - self::STATE_START, + ParserState::TRIM, + ParserState::START, $chars, $len, $multiple, @@ -339,33 +320,33 @@ private function parseInternal(string $emails, bool $multiple, string $encoding) $prevChar = $curChar; // Previous Character $curChar = $chars[$i]; // Current Character switch ($ctx->state) { - case self::STATE_SKIP_AHEAD: + case ParserState::SKIP_AHEAD: $this->handleStateSkipAhead($ctx, $curChar); break; /* @noinspection PhpMissingBreakStatementInspection — STATE_TRIM falls through to STATE_ADDRESS */ - case self::STATE_TRIM: + case ParserState::TRIM: if (!$this->handleStateTrim($ctx, $curChar)) { break; } // no break — a plain character falls through to STATE_ADDRESS - case self::STATE_ADDRESS: + case ParserState::ADDRESS: $this->handleStateAddress($ctx, $curChar, $prevChar, $i); break; - case self::STATE_SQUARE_BRACKET: + case ParserState::SQUARE_BRACKET: $this->handleStateSquareBracket($ctx, $curChar); break; - case self::STATE_OBS_ROUTE: + case ParserState::OBS_ROUTE: $this->handleStateObsRoute($ctx, $curChar); break; - case self::STATE_QUOTE: + case ParserState::QUOTE: $this->handleStateQuote($ctx, $curChar, $i); break; - case self::STATE_COMMENT: + case ParserState::COMMENT: $this->handleStateComment($ctx, $curChar); break; @@ -375,13 +356,13 @@ private function parseInternal(string $emails, bool $multiple, string $encoding) $ctx->invalid = true; $ctx->invalidReason = 'Error during parsing'; $ctx->invalidReasonCode = Err::ParseError; - $this->log('error', "Email\\Parse->parse - error during parsing - \$state: {$ctx->state}\n\$subState: {$ctx->subState}\n\$i: {$i}\n\$curChar: {$curChar}"); + $this->log('error', "Email\\Parse->parse - error during parsing - \$state: {$ctx->state->name}\n\$subState: {$ctx->subState->name}\n\$i: {$i}\n\$curChar: {$curChar}"); break; } // if there's a $ctx->originalAddress and the state is set to STATE_END_ADDRESS - if (self::STATE_END_ADDRESS == $ctx->state && strlen($ctx->originalAddress) > 0) { + if (ParserState::END_ADDRESS == $ctx->state && strlen($ctx->originalAddress) > 0) { $invalid = $this->addAddress( $emailAddresses, $ctx, @@ -398,7 +379,7 @@ private function parseInternal(string $emails, bool $multiple, string $encoding) } // Reset all per-address state before the next address in the batch. - $ctx->resetAddress(self::STATE_TRIM, self::STATE_START); + $ctx->resetAddress(ParserState::TRIM, ParserState::START); } // Fire once, on the transition into invalid: STATE_SKIP_AHEAD does not clear @@ -406,9 +387,9 @@ private function parseInternal(string $emails, bool $multiple, string $encoding) // character — and interpolating the full $emails / original_address each time // (even under a NullLogger, the argument is still built) makes malformed input // O(n^2). See the DoS regression benchmark. - if ($ctx->invalid && self::STATE_SKIP_AHEAD !== $ctx->state) { + if ($ctx->invalid && ParserState::SKIP_AHEAD !== $ctx->state) { $this->log('debug', "Email\\Parse->parse - invalid - {$ctx->invalidReason}\n\$ctx->originalAddress {$ctx->originalAddress}\n\$emails: {$emails}"); - $ctx->state = self::STATE_SKIP_AHEAD; + $ctx->state = ParserState::SKIP_AHEAD; } } @@ -416,13 +397,13 @@ private function parseInternal(string $emails, bool $multiple, string $encoding) // literal, or obs-route) — the construct was never closed. Keyed on the // parser state rather than quote_temp, since bracket/comment content is // buffered elsewhere (a closed delimiter always returns to STATE_ADDRESS). - if (!$ctx->invalid && in_array($ctx->state, [self::STATE_QUOTE, self::STATE_COMMENT, self::STATE_SQUARE_BRACKET, self::STATE_OBS_ROUTE], true)) { + if (!$ctx->invalid && in_array($ctx->state, [ParserState::QUOTE, ParserState::COMMENT, ParserState::SQUARE_BRACKET, ParserState::OBS_ROUTE], true)) { $ctx->invalid = true; [$ctx->invalidReason, $ctx->invalidReasonCode] = match ($ctx->state) { - self::STATE_QUOTE => ['No ending quote: \'"\'', Err::UnterminatedQuote], - self::STATE_COMMENT => ['No closing parenthesis: \')\'', Err::UnterminatedComment], - self::STATE_SQUARE_BRACKET => ['No closing square bracket: \']\'', Err::UnterminatedSquareBracket], - self::STATE_OBS_ROUTE => ['Incomplete obs-route: missing colon before end of input', Err::IncompleteAddress], + ParserState::QUOTE => ['No ending quote: \'"\'', Err::UnterminatedQuote], + ParserState::COMMENT => ['No closing parenthesis: \')\'', Err::UnterminatedComment], + ParserState::SQUARE_BRACKET => ['No closing square bracket: \']\'', Err::UnterminatedSquareBracket], + ParserState::OBS_ROUTE => ['Incomplete obs-route: missing colon before end of input', Err::IncompleteAddress], }; } if (!$ctx->invalid && ($ctx->addressTemp || $ctx->quoteTemp)) { @@ -485,7 +466,7 @@ private function handleStateSkipAhead(ParseContext $ctx, string $curChar): void $isWhitespaceSeparator = $ctx->useWhitespaceAsSeparator && isset($ctx->allowedWhitespace[$curChar]); if ($ctx->multiple && ($isWhitespaceSeparator || isset($ctx->separators[$curChar]))) { - $ctx->state = self::STATE_END_ADDRESS; + $ctx->state = ParserState::END_ADDRESS; } else { $ctx->originalAddress .= $curChar; } @@ -502,16 +483,16 @@ private function handleStateTrim(ParseContext $ctx, string $curChar): bool if (isset($ctx->allowedWhitespace[$curChar])) { return false; } - $ctx->state = self::STATE_ADDRESS; + $ctx->state = ParserState::ADDRESS; if ('"' == $curChar) { $ctx->originalAddress .= $curChar; - $ctx->state = self::STATE_QUOTE; + $ctx->state = ParserState::QUOTE; return false; } if ('(' == $curChar) { $ctx->originalAddress .= $curChar; - $ctx->state = self::STATE_COMMENT; + $ctx->state = ParserState::COMMENT; // A leading comment opens at nest level 1 (matches the // STATE_ADDRESS entry); without this an unbalanced nested // comment like "((x)" would appear closed after one ")". @@ -560,15 +541,15 @@ private function handleStateAddress(ParseContext $ctx, string $curChar, ?string if ('(' == $curChar) { // Handle comment - $ctx->state = self::STATE_COMMENT; + $ctx->state = ParserState::COMMENT; $ctx->commentNestLevel = 1; return; } elseif (isset($ctx->separators[$curChar])) { // Handle separator (comma, semicolon, etc.) - if ($ctx->multiple && (self::STATE_DOMAIN == $ctx->subState || self::STATE_AFTER_DOMAIN == $ctx->subState)) { + if ($ctx->multiple && (ParserState::DOMAIN == $ctx->subState || ParserState::AFTER_DOMAIN == $ctx->subState)) { // If we're already in the domain part, this should be the end of the address - $ctx->state = self::STATE_END_ADDRESS; + $ctx->state = ParserState::END_ADDRESS; return; } else { @@ -587,13 +568,13 @@ private function handleStateAddress(ParseContext $ctx, string $curChar, ?string } } elseif ('<' == $curChar) { // Start of the local part - if (self::STATE_LOCAL_PART == $ctx->subState || self::STATE_DOMAIN == $ctx->subState) { + if (ParserState::LOCAL_PART == $ctx->subState || ParserState::DOMAIN == $ctx->subState) { $ctx->invalid = true; $ctx->invalidReason = 'Email address contains multiple opening "<" (either a typo or multiple emails that need to be separated by a comma or space)'; $ctx->invalidReasonCode = Err::MultipleOpeningAngle; } else { // Here should be the start of the local part for sure everything else then is part of the name - $ctx->subState = self::STATE_LOCAL_PART; + $ctx->subState = ParserState::LOCAL_PART; $ctx->specialCharInSubstate = null; $ctx->inAngleAddr = true; // Any quote before `<` was the display name, not the local part; @@ -611,10 +592,10 @@ private function handleStateAddress(ParseContext $ctx, string $curChar, ?string // domain-literal (``, `]` transitions to AFTER_DOMAIN) // or trailing CFWS reaches — but only when a domain or IP is actually // present, so `` / `` still fail. - if (self::STATE_DOMAIN == $ctx->subState - || (self::STATE_AFTER_DOMAIN == $ctx->subState + if (ParserState::DOMAIN == $ctx->subState + || (ParserState::AFTER_DOMAIN == $ctx->subState && ('' !== $ctx->domain || '' !== $ctx->ip))) { - $ctx->subState = self::STATE_AFTER_DOMAIN; + $ctx->subState = ParserState::AFTER_DOMAIN; $ctx->inAngleAddr = false; } else { $ctx->invalid = true; @@ -623,12 +604,12 @@ private function handleStateAddress(ParseContext $ctx, string $curChar, ?string } } elseif ('"' == $curChar) { // If we hit a quote - change to the quote state, unless it's in the domain, in which case it's error - if (self::STATE_DOMAIN == $ctx->subState || self::STATE_AFTER_DOMAIN == $ctx->subState) { + if (ParserState::DOMAIN == $ctx->subState || ParserState::AFTER_DOMAIN == $ctx->subState) { $ctx->invalid = true; $ctx->invalidReason = 'Quote \'"\' found where it shouldn\'t be'; $ctx->invalidReasonCode = Err::MisplacedQuote; } else { - $ctx->state = self::STATE_QUOTE; + $ctx->state = ParserState::QUOTE; } } elseif ('@' == $curChar) { $this->handleAddressAt($ctx); @@ -638,7 +619,7 @@ private function handleStateAddress(ParseContext $ctx, string $curChar, ?string // part, and not after domain characters or a first literal. Accepting // it mid-domain used to set both domain and ip and surface as an // internal "parser confusion" error. - if (self::STATE_DOMAIN != $ctx->subState) { + if (ParserState::DOMAIN != $ctx->subState) { $ctx->invalid = true; $ctx->invalidReason = "Invalid character '[' in email address"; $ctx->invalidReasonCode = Err::InvalidOpeningBracket; @@ -647,7 +628,7 @@ private function handleStateAddress(ParseContext $ctx, string $curChar, ?string $ctx->invalidReason = "A domain literal '[...]' must be the entire domain, not combined with other domain characters"; $ctx->invalidReasonCode = Err::InvalidOpeningBracket; } else { - $ctx->state = self::STATE_SQUARE_BRACKET; + $ctx->state = ParserState::SQUARE_BRACKET; } } elseif ('.' == $curChar) { // Period placement (RFC 5322 §3.4) — inlined as it is per-character hot. @@ -656,7 +637,7 @@ private function handleStateAddress(ParseContext $ctx, string $curChar, ?string $ctx->invalid = true; $ctx->invalidReason = "Email address should not contain two dots '.' in a row"; $ctx->invalidReasonCode = Err::ConsecutiveDots; - } elseif (self::STATE_LOCAL_PART == $ctx->subState) { + } elseif (ParserState::LOCAL_PART == $ctx->subState) { if (!$ctx->localPartParsed && !$this->options->allowObsLocalPart) { // Leading dots only allowed when obs-local-part is enabled $ctx->invalid = true; @@ -665,13 +646,13 @@ private function handleStateAddress(ParseContext $ctx, string $curChar, ?string } else { $ctx->localPartParsed .= $curChar; } - } elseif (self::STATE_DOMAIN == $ctx->subState) { + } elseif (ParserState::DOMAIN == $ctx->subState) { $ctx->domain .= $curChar; - } elseif (self::STATE_AFTER_DOMAIN == $ctx->subState) { + } elseif (ParserState::AFTER_DOMAIN == $ctx->subState) { $ctx->invalid = true; $ctx->invalidReason = "Stray period '.' found after domain of email address"; $ctx->invalidReasonCode = Err::StrayPeriodAfterDomain; - } elseif (self::STATE_START == $ctx->subState) { + } elseif (ParserState::START == $ctx->subState) { if ($ctx->quoteTemp) { $ctx->addressTemp .= $ctx->quoteTemp; $ctx->addressTempQuoted = true; @@ -699,7 +680,7 @@ private function handleStateAddress(ParseContext $ctx, string $curChar, ?string $ctx->invalid = true; $ctx->invalidReason = "This character is not allowed at the beginning of an email address (please put in quotes if needed): '{$curChar}'"; $ctx->invalidReasonCode = Err::InvalidCharacterAtStart; - } elseif (self::STATE_LOCAL_PART == $ctx->subState) { + } elseif (ParserState::LOCAL_PART == $ctx->subState) { // Legitimate character - Determine where to append based on the current 'substate' if ($ctx->quoteTemp) { @@ -708,14 +689,14 @@ private function handleStateAddress(ParseContext $ctx, string $curChar, ?string $ctx->localPartQuoted = true; } $ctx->localPartParsed .= $curChar; - } elseif (self::STATE_NAME == $ctx->subState) { + } elseif (ParserState::NAME == $ctx->subState) { if ($ctx->quoteTemp) { $ctx->nameParsed .= $ctx->quoteTemp; $ctx->quoteTemp = ''; $ctx->nameQuoted = true; } $ctx->nameParsed .= $curChar; - } elseif (self::STATE_DOMAIN == $ctx->subState) { + } elseif (ParserState::DOMAIN == $ctx->subState) { $ctx->domain .= $curChar; } else { if ($ctx->quoteTemp) { @@ -768,7 +749,7 @@ private function handleAddressWhitespace(ParseContext $ctx, string $curChar, int // rejected per-address (whitespace still separates addresses). $cfwsAbsorbed = false; if (!$foundComment && $lookAheadChar !== null && !($ctx->multiple && $this->options->strictMultiWhitespace)) { - if (self::STATE_LOCAL_PART === $ctx->subState) { + if (ParserState::LOCAL_PART === $ctx->subState) { if ('@' === $lookAheadChar) { // Trailing CFWS of the local-part dot-atom: "local @domain". $cfwsAbsorbed = true; @@ -781,13 +762,13 @@ private function handleAddressWhitespace(ParseContext $ctx, string $curChar, int // Leading CFWS inside angle-addr: "< local@domain>". $cfwsAbsorbed = true; } - } elseif (self::STATE_DOMAIN === $ctx->subState) { + } elseif (ParserState::DOMAIN === $ctx->subState) { if ($ctx->domain === '' && $ctx->ip === '') { // Leading CFWS of the domain dot-atom: "local@ domain". $cfwsAbsorbed = true; } } elseif ( - self::STATE_START === $ctx->subState + ParserState::START === $ctx->subState && '@' === $lookAheadChar && $ctx->addressTemp !== '' ) { @@ -801,34 +782,34 @@ private function handleAddressWhitespace(ParseContext $ctx, string $curChar, int if ($cfwsAbsorbed) { // Silently skip the whitespace character; state unchanged. } elseif ($foundComment) { - if (self::STATE_DOMAIN == $ctx->subState) { - $ctx->subState = self::STATE_AFTER_DOMAIN; - } elseif (self::STATE_LOCAL_PART == $ctx->subState) { + if (ParserState::DOMAIN == $ctx->subState) { + $ctx->subState = ParserState::AFTER_DOMAIN; + } elseif (ParserState::LOCAL_PART == $ctx->subState) { $ctx->invalid = true; $ctx->invalidReason = 'Email address contains whitespace'; $ctx->invalidReasonCode = Err::WhitespaceInAddress; } } elseif ( $ctx->inAngleAddr - && self::STATE_DOMAIN == $ctx->subState + && ParserState::DOMAIN == $ctx->subState && $lookAheadChar === '>' ) { // Trailing CFWS inside angle-addr before `>`: "". // Absorb and transition as if we saw `>` next. - $ctx->subState = self::STATE_AFTER_DOMAIN; + $ctx->subState = ParserState::AFTER_DOMAIN; } elseif ( $ctx->multiple && $lookAheadChar !== null && isset($ctx->separators[$lookAheadChar]) - && (self::STATE_DOMAIN == $ctx->subState || self::STATE_AFTER_DOMAIN == $ctx->subState) + && (ParserState::DOMAIN == $ctx->subState || ParserState::AFTER_DOMAIN == $ctx->subState) ) { // Whitespace between the domain and a following separator // ("a@b.com , c@d.com"): absorb it and let the separator terminate // the address, rather than ending here and leaving the separator to // open an empty next address (a "misplaced separator" error). - $ctx->subState = self::STATE_AFTER_DOMAIN; + $ctx->subState = ParserState::AFTER_DOMAIN; } elseif ($ctx->useWhitespaceAsSeparator && - (self::STATE_DOMAIN == $ctx->subState || self::STATE_AFTER_DOMAIN == $ctx->subState)) { + (ParserState::DOMAIN == $ctx->subState || ParserState::AFTER_DOMAIN == $ctx->subState)) { // Already past `@` and whitespace-as-separator: end address. // Single mode has no next address to separate; if the trailing // whitespace run contains a whitespace char excluded from the @@ -845,11 +826,11 @@ private function handleAddressWhitespace(ParseContext $ctx, string $curChar, int } } } - $ctx->state = self::STATE_END_ADDRESS; + $ctx->state = ParserState::END_ADDRESS; return true; } else { - if (self::STATE_LOCAL_PART == $ctx->subState) { + if (ParserState::LOCAL_PART == $ctx->subState) { $ctx->invalid = true; $ctx->invalidReason = 'Email address contains whitespace'; $ctx->invalidReasonCode = Err::WhitespaceInAddress; @@ -869,11 +850,11 @@ private function handleAddressWhitespace(ParseContext $ctx, string $curChar, int */ private function handleAddressAt(ParseContext $ctx): void { - if (self::STATE_DOMAIN == $ctx->subState) { + if (ParserState::DOMAIN == $ctx->subState) { $ctx->invalid = true; $ctx->invalidReason = "Multiple at '@' symbols in email address"; $ctx->invalidReasonCode = Err::MultipleAtSymbols; - } elseif (self::STATE_AFTER_DOMAIN == $ctx->subState) { + } elseif (ParserState::AFTER_DOMAIN == $ctx->subState) { $ctx->invalid = true; $ctx->invalidReason = "Stray at '@' symbol found after domain name"; $ctx->invalidReasonCode = Err::StrayAtAfterDomain; @@ -902,10 +883,10 @@ private function handleAddressAt(ParseContext $ctx): void // preceding local-part starts the source-route prefix. Consume // the remainder until `:` via STATE_OBS_ROUTE, then resume // addr-spec parsing with local-part reset. - $ctx->state = self::STATE_OBS_ROUTE; + $ctx->state = ParserState::OBS_ROUTE; $ctx->obsRoute = '@'; } else { - $ctx->subState = self::STATE_DOMAIN; + $ctx->subState = ParserState::DOMAIN; // A trailing quoted word after earlier words ("x"."y", x."y") // is the final word of an obs-local-part (RFC 5322 §3.4.1: // word *("." word), word = atom / quoted-string). Flush it onto @@ -936,7 +917,7 @@ private function handleAddressAt(ParseContext $ctx): void */ private function handleAddressNonAtext(ParseContext $ctx, string $curChar): void { - if (self::STATE_DOMAIN == $ctx->subState) { + if (ParserState::DOMAIN == $ctx->subState) { if ($this->isUtf8Char($curChar)) { $ctx->domain .= $curChar; } else { @@ -958,13 +939,13 @@ private function handleAddressNonAtext(ParseContext $ctx, string $curChar): void $ctx->invalidReasonCode = Err::InvalidCharacterInDomain; } } - } elseif (self::STATE_START === $ctx->subState || self::STATE_LOCAL_PART === $ctx->subState) { + } elseif (ParserState::START === $ctx->subState || ParserState::LOCAL_PART === $ctx->subState) { // Handle non-atext characters in both STATE_START and STATE_LOCAL_PART consistently - if ($ctx->subState === self::STATE_START && $ctx->quoteTemp) { + if ($ctx->subState === ParserState::START && $ctx->quoteTemp) { $ctx->addressTemp .= $ctx->quoteTemp; $ctx->addressTempQuoted = true; $ctx->quoteTemp = ''; - } elseif ($ctx->subState === self::STATE_LOCAL_PART && $ctx->quoteTemp) { + } elseif ($ctx->subState === ParserState::LOCAL_PART && $ctx->quoteTemp) { $ctx->localPartParsed .= $ctx->quoteTemp; $ctx->quoteTemp = ''; $ctx->localPartQuoted = true; @@ -974,14 +955,14 @@ private function handleAddressNonAtext(ParseContext $ctx, string $curChar): void if ($isUtf8 && $this->options->allowUtf8LocalPart) { // UTF-8 character allowed - if ($ctx->subState === self::STATE_START) { + if ($ctx->subState === ParserState::START) { $ctx->addressTemp .= $curChar; } else { $ctx->localPartParsed .= $curChar; } } elseif ($isUtf8) { // UTF-8 present but not allowed by rules — collect and reject in validateLocalPart() - if ($ctx->subState === self::STATE_START) { + if ($ctx->subState === ParserState::START) { $ctx->addressTemp .= $curChar; // ??= preserves the first invalid character seen; later chars must not overwrite it $ctx->specialCharInSubstate ??= $curChar; @@ -992,7 +973,7 @@ private function handleAddressNonAtext(ParseContext $ctx, string $curChar): void } } else { // Non-UTF-8, non-atext character - if ($ctx->subState === self::STATE_START) { + if ($ctx->subState === ParserState::START) { // ??= preserves the first invalid character seen; later chars must not overwrite it $ctx->specialCharInSubstate ??= $curChar; $ctx->addressTemp .= $curChar; @@ -1002,7 +983,7 @@ private function handleAddressNonAtext(ParseContext $ctx, string $curChar): void $ctx->invalidReasonCode = Err::InvalidCharacterInLocalPart; } } - } elseif (self::STATE_NAME === $ctx->subState) { + } elseif (ParserState::NAME === $ctx->subState) { if ($ctx->quoteTemp) { $ctx->nameParsed .= $ctx->quoteTemp; $ctx->quoteTemp = ''; @@ -1024,8 +1005,8 @@ private function handleStateSquareBracket(ParseContext $ctx, string $curChar): v { $ctx->originalAddress .= $curChar; if (']' == $curChar) { - $ctx->subState = self::STATE_AFTER_DOMAIN; - $ctx->state = self::STATE_ADDRESS; + $ctx->subState = ParserState::AFTER_DOMAIN; + $ctx->state = ParserState::ADDRESS; } else { $ctx->ip .= $curChar; } @@ -1040,16 +1021,16 @@ private function handleStateObsRoute(ParseContext $ctx, string $curChar): void { $ctx->originalAddress .= $curChar; if (':' == $curChar) { - $ctx->state = self::STATE_ADDRESS; - $ctx->subState = self::STATE_LOCAL_PART; + $ctx->state = ParserState::ADDRESS; + $ctx->subState = ParserState::LOCAL_PART; } elseif ('>' == $curChar) { // `<@host>` without a colon — incomplete obs-route. $ctx->invalid = true; $ctx->invalidReason = 'Incomplete obs-route: missing colon before closing angle-bracket'; $ctx->invalidReasonCode = Err::IncompleteAddress; $ctx->inAngleAddr = false; - $ctx->state = self::STATE_ADDRESS; - $ctx->subState = self::STATE_AFTER_DOMAIN; + $ctx->state = ParserState::ADDRESS; + $ctx->subState = ParserState::AFTER_DOMAIN; } else { $ctx->obsRoute .= $curChar; } @@ -1086,7 +1067,7 @@ private function handleStateQuote(ParseContext $ctx, string $curChar, int $i): v // empty in that case, so the '@' handler below can't tell. A // display-name quote self-corrects: the real local-part resets // this flag from address_temp_quoted when '@' is reached. - $ctx->state = self::STATE_ADDRESS; + $ctx->state = ParserState::ADDRESS; $ctx->localPartQuoted = true; $ctx->afterClosingQuote = true; } @@ -1124,13 +1105,13 @@ private function handleStateComment(ParseContext $ctx, string $curChar): void $ctx->comments[] = $ctx->commentTemp; $ctx->commentTemp = ''; } - $ctx->state = self::STATE_ADDRESS; + $ctx->state = ParserState::ADDRESS; // Flag a comment that closed mid-word in the local part (before // `@`), so a token resuming the word can be rejected. Covers a // preceding atext run (address_temp/local_part_parsed) or a // preceding quoted-string (local_part_quoted) — "x"(c)y is as // invalid as x(c)y. Domain and display-name comments are excluded. - if ((self::STATE_LOCAL_PART === $ctx->subState || self::STATE_START === $ctx->subState) + if ((ParserState::LOCAL_PART === $ctx->subState || ParserState::START === $ctx->subState) && ('' !== $ctx->addressTemp || '' !== $ctx->localPartParsed || $ctx->localPartQuoted)) { $ctx->commentAfterLocalAtext = true; } diff --git a/src/ParseContext.php b/src/ParseContext.php index ad9d6f8..8ee8876 100644 --- a/src/ParseContext.php +++ b/src/ParseContext.php @@ -30,16 +30,11 @@ final class ParseContext // --- Loop control state (state/subState reset per address by parse()). --- - /** Current parser state (one of Parse::STATE_*). */ - public int $state = 0; + /** Current parser state. Initialized by the constructor / resetAddress(). */ + public ParserState $state; - /** - * Current parser sub-state within an addr-spec (one of Parse::STATE_*). - * Initialized by the constructor / resetAddress(); the literal 0 default is - * STATE_TRIM, not a valid starting sub-state (which is STATE_START), so it - * must never be relied on un-initialized. - */ - public int $subState = 0; + /** Current parser sub-state within an addr-spec. Set by the constructor / resetAddress(). */ + public ParserState $subState; /** Current comment nesting depth. */ public int $commentNestLevel = 0; @@ -129,8 +124,8 @@ final class ParseContext public string $obsRoute = ''; /** - * @param int $state Initial parser state (a Parse::STATE_* value). - * @param int $subState Initial addr-spec sub-state (a Parse::STATE_* value). + * @param ParserState $state Initial parser state. + * @param ParserState $subState Initial addr-spec sub-state. * @param array $chars Input split into characters. * @param int $len Number of characters in $chars. * @param bool $multiple Whether multiple addresses are being parsed. @@ -141,8 +136,8 @@ final class ParseContext * @param array $allowedWhitespace Insignificant (foldable/trimmable) whitespace, as a lookup map. */ public function __construct( - int $state, - int $subState, + ParserState $state, + ParserState $subState, public readonly array $chars, public readonly int $len, public readonly bool $multiple, @@ -163,10 +158,10 @@ public function __construct( * for the next address in a multi-address parse (matches the historical * "rebuild the $emailAddress array" behaviour). * - * @param int $state Parser state to start the next address in (Parse::STATE_*). - * @param int $subState Addr-spec sub-state to start it in (Parse::STATE_*). + * @param ParserState $state Parser state to start the next address in. + * @param ParserState $subState Addr-spec sub-state to start it in. */ - public function resetAddress(int $state, int $subState): void + public function resetAddress(ParserState $state, ParserState $subState): void { // Loop-control state, reset here so every per-address field has a single // source of truth. commentNestLevel in particular has no other reset: diff --git a/src/ParserState.php b/src/ParserState.php new file mode 100644 index 0000000..aabdd5c --- /dev/null +++ b/src/ParserState.php @@ -0,0 +1,37 @@ +"`). + * Consumes characters from the leading `@` up to the `:` terminator, + * then resumes normal addr-spec parsing. + */ + case OBS_ROUTE = 12; +} From e4ced59a9623c91166f2fc328f6c08674ea6fd9c Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Thu, 27 Aug 2026 22:30:38 -0700 Subject: [PATCH 07/23] ci(psalm): suppress PossiblyUnusedMethod on the deprecated parse() shim parse() has no internal callers now (the typed methods use parseInternal() directly), so Psalm's findUnusedCode flags the public deprecated shim. It's still called by external code until its 5.0 removal. (Local runs passed on a stale cache; CI runs fresh.) --- src/Parse.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Parse.php b/src/Parse.php index 364c346..d24cec2 100644 --- a/src/Parse.php +++ b/src/Parse.php @@ -205,6 +205,10 @@ private function validateIpGlobalRange(string $ip, int $ipType): bool * 'invalid' => boolean, 'invalid_reason' => string|null, * 'invalid_reason_code' => ParseErrorCode|null, 'comments' => array) * endif; + * + * @psalm-suppress PossiblyUnusedMethod Deprecated public API — the typed + * methods use parseInternal() directly, so nothing internal calls this, + * but external code still does until its 5.0 removal. */ public function parse(string $emails, bool $multiple = true, string $encoding = 'UTF-8'): array { From 4bf937f66f84176d210d0c87c1fe374818d53141 Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Thu, 27 Aug 2026 22:45:03 -0700 Subject: [PATCH 08/23] docs: add v3.x -> v4.0 UPGRADE guide; reshuffle roadmap to lean 4.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UPGRADE.md: new v3.x -> v4.0 section — breaking changes (removed ParseOptions setters with a setter -> withX() migration table; validateLocalPart/ validateDomainName now private), the parse()/getInstance() deprecations, the readonly state fields, and a no-action note for the internal changes (ParserState enum, ParseContext modernization). ROADMAP.md: 4.0 is now explicitly lean (breaking cleanup + internal modernization only). Moved the deferred features out: DNS/MX -> v4.1, confusable-against-target-list -> v4.2 (both additive), and RFC 6854 group syntax -> v5.0 (breaking: new group-node result shape). --- ROADMAP.md | 14 ++++++--- UPGRADE.md | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7f47e31..97412e4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -82,15 +82,21 @@ Continuous work, not tied to a specific release. - [x] **Deprecate** the `getInstance()` singleton (recommend explicit instantiation — the static singleton carries process-global state and is pinned to the LEGACY preset). Kept working; **removal deferred to 5.0**. - [x] Removed the deprecated `Parse::validateLocalPart(array)` extension point (deprecated in 3.9) — local-part validation is now a `private`, `ParseContext`-based method, and `validateDomainName()` is `private` too. They took the parser's internal accumulator and were never a supported extension point — validation is customized through `ParseOptions`. -**New capabilities (breaking or late-binding):** -- [ ] DNS/MX validation via a `DnsValidator` callback interface — breaking because the `Parse` constructor grows, and synchronous lookups change performance characteristics. -- [ ] Group syntax (RFC 6854: `Group Name: addr1, addr2;`) — introduces a new output-container shape for grouped results. -- [ ] Confusable-against-a-target-list matching — compare the domain's Unicode skeleton against a caller-supplied brand/skeleton set (`Spoofchecker::areConfusable()`), following on from the v3.8 single-string check. Deferred until the caller-provided target list is designed. +_v4.0 is deliberately **lean**: breaking API cleanup + internal modernization only (see [UPGRADE.md](UPGRADE.md)), so it ships fast and upgrades mechanically. The larger new features once slated here are additive and move to later minors; only the genuinely breaking one (group syntax) moves to 5.0. Internal modernization (`ParseContext` camelCase + readonly, the `ParserState` enum) is tracked under the Backlog below._ + +### v4.1 — planned + +- [ ] DNS/MX validation via a `DnsValidator` callback interface. Additive and opt-in (off by default): the `Parse` constructor gains a parameter, and synchronous lookups change performance characteristics, so callers choose it explicitly. + +### v4.2 — planned + +- [ ] Confusable-against-a-target-list matching — compare the domain's Unicode skeleton against a caller-supplied brand/skeleton set (`Spoofchecker::areConfusable()`), following on from the v3.8 single-string check. Additive; deferred until the caller-provided target-list API is designed. ### v5.0 — planned - [ ] Remove the deprecated `parse()` method (deprecated in 4.0). `parseSingle()` / `parseMultiple()` / `parseStream()` are the entry points; the private `parseInternal()` core stays. - [ ] Remove the deprecated `Parse::getInstance()` singleton (deprecated in 4.0). Use `new Parse($logger, $options)`. +- [ ] **RFC 6854 group syntax** (`Group Name: addr1, addr2;`; empty groups `Name:;`) — parse the group construct (RFC 5322 §3.4 / RFC 6854). Breaking: a group is a *named container* of mailboxes, not a single address, so `parseMultiple()`'s result gains a new group-node shape. ### Backlog (unversioned) diff --git a/UPGRADE.md b/UPGRADE.md index 0de5c9d..f2dabcb 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,5 +1,90 @@ # Upgrade Guide +## v3.x → v4.0 + +v4.0 is a **breaking-modernization** release. Parsing behavior is unchanged — no changes to parsing logic, error codes, or the output shape — so a valid address parses identically. The breaks are all API-surface cleanup: the long-deprecated mutating setters are gone, `ParseOptions` config is now fully immutable, and two internal methods became `private`. Two public methods are newly deprecated (they still work). + +For most callers the upgrade is a mechanical find-and-replace. If you only ever call `parseSingle()` / `parseMultiple()` / `parseStream()` and configure options with the constructor or `withX()` builders, **no changes are required.** + +### Breaking Changes + +#### 1. `ParseOptions` mutating setters removed + +The seven setters that were `@deprecated` since v3.0 are removed. `ParseOptions` is now a fully immutable value object: every property is `readonly`, and you configure a new instance via the constructor or the `withX()` fluent builders. + +The one behavioral difference to watch: `withX()` returns a **new** instance, so you must **reassign** — the old setters mutated in place. + +| Removed setter | Replacement | +|---|---| +| `$o->setBannedChars($a)` | `$o = $o->withBannedChars($a)` | +| `$o->setSeparators($a)` | `$o = $o->withSeparators($a)` | +| `$o->setUseWhitespaceAsSeparator($b)` | `$o = $o->withUseWhitespaceAsSeparator($b)` | +| `$o->setLengthLimits($l)` | `$o = $o->withLengthLimits($l)` | +| `$o->setMaxLocalPartLength($n)` | `$o = $o->withLengthLimits(new LengthLimits($n, $o->getMaxTotalLength(), $o->getMaxDomainLabelLength()))` | +| `$o->setMaxTotalLength($n)` | `$o = $o->withLengthLimits(new LengthLimits($o->getMaxLocalPartLength(), $n, $o->getMaxDomainLabelLength()))` | +| `$o->setMaxDomainLabelLength($n)` | `$o = $o->withLengthLimits(new LengthLimits($o->getMaxLocalPartLength(), $o->getMaxTotalLength(), $n))` | + +```php +// Before (v3.x) +$options = new ParseOptions(); +$options->setBannedChars(['%', '!']); +$options->setSeparators([',', ';']); + +// After (v4.0) — reassign; each withX() returns a new instance +$options = (new ParseOptions()) + ->withBannedChars(['%', '!']) + ->withSeparators([',', ';']); +``` + +The `getX()` accessors (`getBannedChars()`, `getSeparators()`, `getLengthLimits()`, `getMaxLocalPartLength()`, …) are unchanged, and the state fields are now also readable directly as `public readonly` properties (`$options->bannedChars`, `$options->separators`, etc.). + +#### 2. `Parse::validateLocalPart()` and `validateDomainName()` are now `private` + +These took the parser's internal accumulator and were never a documented extension point. If you subclassed `Parse` to override either, move that logic to `ParseOptions` configuration (rule properties, or the `withLocalPartNormalizer()` callback). `validateLocalPart()`'s brief `array`-signature deprecation window in 3.9 is now closed. + +### Deprecated (Still Functional) + +Both keep working in the entire 4.x line and are removed in **5.0**. + +#### 1. `Parse::parse()` + +The polymorphic `$multiple`-boolean, array-returning method is deprecated in favor of the typed API: + +```php +// Before +$rows = $parser->parse($input, true); // array of address arrays +$row = $parser->parse($input, false); // single address array + +// After +$result = $parser->parseMultiple($input); // ParseResult (typed) +$addr = $parser->parseSingle($input); // ParsedEmailAddress (typed) + +// Need the legacy array shape? Call ->toArray(): +$rows = $parser->parseMultiple($input)->toArray()['email_addresses']; +$row = $parser->parseSingle($input)->toArray(); +``` + +#### 2. `Parse::getInstance()` + +The default-options singleton is deprecated — it carries process-global state and is pinned to the LEGACY preset (permissive v2.x behavior). Instantiate explicitly, which also lets you pass a logger and custom options: + +```php +// Before +$parser = Parse::getInstance(); + +// After +$parser = new Parse(); // default options +$parser = new Parse(null, ParseOptions::rfc5322()); // configured +``` + +### Internal Changes (No Action Needed) + +These are implementation details behind `@internal` and do not affect callers: the `Parse::STATE_*` constants became a `ParserState` enum, and the internal `ParseContext` accumulator was modernized (camelCase fields, readonly input snapshot/config). They are listed only for completeness — if your code reached into these, it was relying on unsupported internals. + +### Minimum Requirements (Unchanged) + +PHP **8.1+**, with the `mbstring` and `intl` extensions. + ## v3.2 → v3.3 v3.3 is fully additive — no breaking changes, no behavior changes for existing callers. Everything listed here is opt-in. From d0c076cf055b932601c892b98b381c8d3881c32d Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Thu, 27 Aug 2026 22:57:28 -0700 Subject: [PATCH 09/23] docs(roadmap): add North Star (error identity vs presentation) + 4.1 keystone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encodes the strategy connecting three longer-horizon goals — PHP framework integration, localized error messages, and ports to other languages — which all converge on decoupling error identity (ParseErrorCode + named parameters) from presentation (a rendered, localizable string). - New "Strategic direction (North Star)" section. - v4.1 is now the structured-errors keystone: messageParameters on ParsedEmailAddress + a swappable MessageProvider interface (sketched), additive so invalid_reason stays English by default; and making testspec.yml code+params-normative for ports. - DNS/MX -> v4.2, confusable-target -> v4.3. RFC 6854 groups reclassified as additive (Option B: flat emailAddresses + a groups() view) -> 4.3, off 5.0. --- ROADMAP.md | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 97412e4..9066401 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -71,6 +71,21 @@ Continuous work, not tied to a specific release. **Maintainability:** - [x] **`parse()` decomposition** (delivered; unreleased). The ~772-line state-machine loop is now a ~185-line dispatch loop over per-state handler methods, backed by a typed, per-parse `ParseContext` (a fresh instance per call keeps the parser reentrant). Behavior-preserving — same logic, conditions, ordering, and output. See [ARCHITECTURE.md](ARCHITECTURE.md). Follow-ups in the backlog below. +## Strategic direction (North Star) + +Three longer-horizon goals — **PHP framework integration** (Symfony/Laravel), **localized error messages**, and **ports to other languages** (JS/Python/…) — all converge on one architectural principle: + +> **Decouple error _identity_ from error _presentation_.** An error is a `ParseErrorCode` plus named parameters (the language-agnostic identity); the human string is _rendered_ from that (the localizable presentation). + +This single decoupling serves all three: **i18n** = swap the message catalog; **ports** = the spec asserts on `code + parameters`, message text is non-normative and rendered per implementation; **framework hooks** = the framework's translator is the renderer. `ParseErrorCode` already supplies the identity; the 4.1 keystone adds the parameters + a swappable `MessageProvider`. + +Assets that make this credible — protect them as first-class: +- **`testspec.yml`** — a language-agnostic conformance suite; the canonical spec for ports (assert on `invalid_reason_code` + parameters; treat message text as non-normative). +- **`ARCHITECTURE.md`** — the portable algorithm reference for ports. +- **`ParseErrorCode`** — the stable error contract that i18n and ports bind to. + +Framework packages (`email-parse-symfony`, `email-parse-laravel`) live in separate repos on their own track, wrapping the frozen 4.0 typed API and wiring the framework Translator in as the `MessageProvider`. + ## Planned ### v4.0 — Breaking modernization @@ -84,19 +99,37 @@ Continuous work, not tied to a specific release. _v4.0 is deliberately **lean**: breaking API cleanup + internal modernization only (see [UPGRADE.md](UPGRADE.md)), so it ships fast and upgrades mechanically. The larger new features once slated here are additive and move to later minors; only the genuinely breaking one (group syntax) moves to 5.0. Internal modernization (`ParseContext` camelCase + readonly, the `ParserState` enum) is tracked under the Backlog below._ -### v4.1 — planned +### v4.1 — Structured errors (the keystone) -- [ ] DNS/MX validation via a `DnsValidator` callback interface. Additive and opt-in (off by default): the `Parse` constructor gains a parameter, and synchronous lookups change performance characteristics, so callers choose it explicitly. +The highest-leverage post-4.0 work: it unlocks i18n, framework-native localization, and clean ports at once (see North Star above). Additive / non-breaking — `invalid_reason` keeps returning today's English strings by default. + +- [ ] Capture **named message parameters** at each error site and expose them as `ParsedEmailAddress::$messageParameters` (`array`; `[]` when valid). +- [ ] Introduce a `MessageProvider` interface with a built-in `EnglishMessageProvider` default that renders `invalid_reason` from `code + parameters`: + ```php + interface MessageProvider + { + /** @param array $parameters e.g. ['char' => ':'] or ['limit' => 64] */ + public function message(ParseErrorCode $code, array $parameters = [], ?string $locale = null): string; + } + ``` + **Named** (not positional) parameters — word order varies across languages. Frameworks re-render from `code + messageParameters` via their own translator; injecting a custom `MessageProvider` into `Parse` is the optional path for localized `invalid_reason` at the source. +- [ ] Evolve `testspec.yml` so `invalid_reason_code` (+ parameters) is the normative assertion and message text is non-normative — making the spec port-ready. ### v4.2 — planned +- [ ] DNS/MX validation via a `DnsValidator` callback interface. Additive and opt-in (off by default): the `Parse` constructor gains a parameter, and synchronous lookups change performance characteristics, so callers choose it explicitly. + +### v4.3 — planned + - [ ] Confusable-against-a-target-list matching — compare the domain's Unicode skeleton against a caller-supplied brand/skeleton set (`Spoofchecker::areConfusable()`), following on from the v3.8 single-string check. Additive; deferred until the caller-provided target-list API is designed. +- [ ] **RFC 6854 group syntax** (`Group Name: addr1, addr2;`; empty groups `Name:;`) — parse the group construct (RFC 5322 §3.4 / RFC 6854) **additively**: group members flatten into `emailAddresses` unchanged (each gaining an optional `->group` name), plus a `ParseResult::groups()` typed view (`ParsedGroup { string $name; ParsedEmailAddress[] $addresses }`) for callers who want structure, including empty groups. Non-breaking, so it stays a minor — only a *structural* redesign that changed `emailAddresses`' type would force a major. ### v5.0 — planned - [ ] Remove the deprecated `parse()` method (deprecated in 4.0). `parseSingle()` / `parseMultiple()` / `parseStream()` are the entry points; the private `parseInternal()` core stays. - [ ] Remove the deprecated `Parse::getInstance()` singleton (deprecated in 4.0). Use `new Parse($logger, $options)`. -- [ ] **RFC 6854 group syntax** (`Group Name: addr1, addr2;`; empty groups `Name:;`) — parse the group construct (RFC 5322 §3.4 / RFC 6854). Breaking: a group is a *named container* of mailboxes, not a single address, so `parseMultiple()`'s result gains a new group-node shape. + +_(RFC 6854 group syntax moved to 4.2/4.3 — it can be added additively, see above; only a structural redesign of `emailAddresses` would make it a 5.0 break.)_ ### Backlog (unversioned) From 33d39ef1bd5c51a1ac8c94193e0ef71640fbaeb1 Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Thu, 27 Aug 2026 22:58:22 -0700 Subject: [PATCH 10/23] docs(roadmap): reword testspec item --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9066401..b32c354 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -113,7 +113,7 @@ The highest-leverage post-4.0 work: it unlocks i18n, framework-native localizati } ``` **Named** (not positional) parameters — word order varies across languages. Frameworks re-render from `code + messageParameters` via their own translator; injecting a custom `MessageProvider` into `Parse` is the optional path for localized `invalid_reason` at the source. -- [ ] Evolve `testspec.yml` so `invalid_reason_code` (+ parameters) is the normative assertion and message text is non-normative — making the spec port-ready. +- [ ] Restructure `testspec.yml` so `invalid_reason_code` (+ parameters) is the normative assertion and message text is non-normative — making the spec port-ready. ### v4.2 — planned From b5ae0f9f055f320610c4560e7cea63b3639338f3 Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Fri, 28 Aug 2026 00:00:52 -0700 Subject: [PATCH 11/23] test: restore + increase coverage after the 4.0 API changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the suite off the deprecated parse()/getInstance() and removing the setter test left a few methods with no caller. Cover them, and add reachable branch tests: - getInstance() — deprecated but public until 5.0; assert the shared instance. - ParseOptions::getMaxLocalPartLength() and the 5 fluent builders the toggle test was missing (withAllowObsRoute, withTrimSingleAddressWhitespace, withStrictMultiWhitespace, withRejectTrailingDot, withDetectConfusableDomain). - Quoted UTF-8 local part under rfc5321 -> Utf8NotAllowedInLocalPart (validateLocalPart's UTF-8 gate). - Quoted local part re-quoted after a normalizer rewrites it. Every method in Parse and ParseOptions is now covered. Overall lines 95.49% -> 96.18%, methods 87.78% -> 91.11%. --- tests/ParseTest.php | 52 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/ParseTest.php b/tests/ParseTest.php index b2bc5bd..c4bc086 100644 --- a/tests/ParseTest.php +++ b/tests/ParseTest.php @@ -788,6 +788,11 @@ public function testAllFluentBuildersToggleTheTargetedField(): void ['withIncludeDomainAscii', true, 'includeDomainAscii'], ['withValidateDisplayNamePhrase', true, 'validateDisplayNamePhrase'], ['withStrictIdna', true, 'strictIdna'], + ['withAllowObsRoute', true, 'allowObsRoute'], + ['withTrimSingleAddressWhitespace', true, 'trimSingleAddressWhitespace'], + ['withStrictMultiWhitespace', true, 'strictMultiWhitespace'], + ['withRejectTrailingDot', true, 'rejectTrailingDot'], + ['withDetectConfusableDomain', true, 'detectConfusableDomain'], ['withUseWhitespaceAsSeparator', false, null], ]; foreach ($cases as [$method, $value, $property]) { @@ -832,6 +837,7 @@ public function testStateFieldsArePublicReadonlyAndConfigurable(): void $this->assertSame(['%' => true], $opts->getBannedChars()); $this->assertSame([';' => true], $opts->getSeparators()); $this->assertFalse($opts->getUseWhitespaceAsSeparator()); + $this->assertSame(10, $opts->getMaxLocalPartLength()); $this->assertSame(20, $opts->getMaxTotalLength()); $this->assertSame(5, $opts->getMaxDomainLabelLength()); } @@ -1723,4 +1729,50 @@ public function testParserIsReentrantAcrossLocalPartNormalizer(): void $this->assertSame('inner.user', $innerResult->localPart); $this->assertSame('nested.example.org', $innerResult->domain); } + + /** + * getInstance() is deprecated (removed in 5.0) but still a working public + * method: it returns a shared Parse configured with default options. + */ + public function testGetInstanceReturnsSharedDefaultInstance(): void + { + $a = Parse::getInstance(); + $b = Parse::getInstance(); + + $this->assertInstanceOf(Parse::class, $a); + $this->assertSame($a, $b, 'getInstance() must return the same shared instance'); + + // Usable and applies the default (LEGACY) options. + $this->assertFalse($a->parseSingle('john@example.com')->invalid); + } + + /** + * A quoted UTF-8 local part under an ASCII-only preset (rfc5321) is accepted + * structurally by the parser, then rejected by the validation gate with the + * precise code — exercises the allowUtf8LocalPart check in validateLocalPart(). + */ + public function testQuotedUtf8LocalPartRejectedUnderAsciiPreset(): void + { + $result = (new Parse(null, ParseOptions::rfc5321()))->parseSingle('"münchen"@example.com'); + + $this->assertTrue($result->invalid); + $this->assertSame(\Email\ParseErrorCode::Utf8NotAllowedInLocalPart, $result->invalidReasonCode); + } + + /** + * When the local-part normalizer rewrites a *quoted* local part, the display + * form is re-derived with the quotes preserved — exercises the re-quote + * branch after normalization. + */ + public function testQuotedLocalPartIsRequotedAfterNormalizer(): void + { + $opts = ParseOptions::rfc5322() + ->withLocalPartNormalizer(fn (string $local, string $domain): string => strtolower($local)); + $result = (new Parse(null, $opts))->parseSingle('"John Doe"@example.com'); + + $this->assertFalse($result->invalid); + $this->assertSame('john doe', $result->localPartParsed); + // Quotes preserved because the local part still needs quoting. + $this->assertSame('"john doe"', $result->localPart); + } } From dbfbbbb546ee358f84329df3e239dd5bb548845d Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Fri, 28 Aug 2026 19:55:29 -0700 Subject: [PATCH 12/23] ci: make coverage deterministic (pin SEED); add two branch tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "coverage decreased" was measurement noise, not a real regression: PropertyTest fuzzes from a time-based seed by default, so the coverage job's line count (and the Codecov delta) jittered run-to-run — adding tests could even show a lower number. Two seeded coverage runs of identical code differed by ~0.6%. - Pin SEED=12345 for the coverage job so the measured number is deterministic and comparable (this seed lands at 96.18%, above the prior ~95.84% base). The 8.1-8.6 matrix jobs stay unseeded so they keep fuzzing. - Add two behavioral branch tests (mixed quoted/unquoted display name; NFC normalization of an unquoted local part under rfc6531). Coverage is now reproducible; overall ~96.18% lines, every Parse/ParseOptions method covered. 114 tests / 7234 assertions, PHPStan L8, Psalm, CS green. --- .github/workflows/ci.yml | 7 +++++++ tests/ParseTest.php | 28 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a29f45..ab0cefa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,13 @@ jobs: run: composer install --prefer-dist --no-progress --no-suggest - name: Run test suite with coverage + # Pin SEED so coverage is deterministic: PropertyTest fuzzes from a + # time-based seed by default, which makes the measured line count (and + # thus the Codecov delta) jitter run-to-run. A fixed seed gives a + # reproducible, comparable number. The 8.1–8.6 test jobs stay unseeded + # so they keep fuzzing across the matrix. + env: + SEED: '12345' run: bin/phpunit --coverage-clover=coverage.xml --coverage-text - name: Upload coverage to Codecov diff --git a/tests/ParseTest.php b/tests/ParseTest.php index c4bc086..dd3ab66 100644 --- a/tests/ParseTest.php +++ b/tests/ParseTest.php @@ -1775,4 +1775,32 @@ public function testQuotedLocalPartIsRequotedAfterNormalizer(): void // Quotes preserved because the local part still needs quoting. $this->assertSame('"john doe"', $result->localPart); } + + /** + * A display name mixing a quoted word and unquoted atext — the quoted run is + * flushed into the parsed name and parsing continues (exercises the NAME + * sub-state quote-flush branch). + */ + public function testDisplayNameMixesQuotedAndUnquotedWords(): void + { + $result = (new Parse(null, ParseOptions::rfc5322()))->parseSingle('"J" Doe '); + + $this->assertFalse($result->invalid); + $this->assertSame('J Doe', $result->nameParsed); + $this->assertSame('j@example.com', $result->simpleAddress); + } + + /** + * An unquoted local part in NFD form under rfc6531 is NFC-normalized, and the + * display form is re-derived from the normalized value (RFC 6532 §3.1). + */ + public function testUnquotedLocalPartIsNfcNormalized(): void + { + // "cafe" + U+0301 (combining acute) → NFC "café" (U+00E9) + $nfd = "cafe\xCC\x81@example.com"; + $result = (new Parse(null, ParseOptions::rfc6531()))->parseSingle($nfd); + + $this->assertFalse($result->invalid); + $this->assertSame("caf\u{00E9}", $result->localPartParsed); + } } From dde89f465434a35200fe6cd95ddc5e9ac2e44585 Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Fri, 28 Aug 2026 20:31:53 -0700 Subject: [PATCH 13/23] test+cov: cover reachable branches; mark two dead branches ignored Push coverage after the deterministic-seed fix: - Add behavioral edge tests (mid-string quoted display-name word, etc.). - Mark the two provably-unreachable defensive blocks @codeCoverageIgnore: the switch `default:` case (impossible now that $ctx->state is a ParserState enum with every case handled) and the ParserConfusion branch (a 500k-input fuzz confirmed it's dead). These are excluded from the denominator rather than faked with contrived internal-state tests. Overall line coverage 96.18% -> 97.13% (deterministic, SEED=12345). PHPStan L8, Psalm, CS green. --- src/Parse.php | 11 +++++++++-- tests/ParseTest.php | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/Parse.php b/src/Parse.php index d24cec2..e6f359b 100644 --- a/src/Parse.php +++ b/src/Parse.php @@ -354,8 +354,10 @@ private function parseInternal(string $emails, bool $multiple, string $encoding) $this->handleStateComment($ctx, $curChar); break; + // @codeCoverageIgnoreStart default: - // Shouldn't ever get here - what is $ctx->state? + // Unreachable: $ctx->state is a ParserState enum and every case + // is handled above. Kept as defensive depth against a future state. $ctx->originalAddress .= $curChar; $ctx->invalid = true; $ctx->invalidReason = 'Error during parsing'; @@ -363,6 +365,7 @@ private function parseInternal(string $emails, bool $multiple, string $encoding) $this->log('error', "Email\\Parse->parse - error during parsing - \$state: {$ctx->state->name}\n\$subState: {$ctx->subState->name}\n\$i: {$i}\n\$curChar: {$curChar}"); break; + // @codeCoverageIgnoreEnd } // if there's a $ctx->originalAddress and the state is set to STATE_END_ADDRESS @@ -1201,12 +1204,16 @@ private function addAddress( $ctx->invalidReason = 'Incomplete address'; $ctx->invalidReasonCode = Err::IncompleteAddress; $this->log('error', "Email\\Parse->addAddress - corruption during parsing - leftovers:\n\$i: {$i}\n\$ctx->addressTemp : {$ctx->addressTemp}\n\$ctx->quoteTemp: {$ctx->quoteTemp}\n"); + // @codeCoverageIgnoreStart } elseif ($ctx->ip && $ctx->domain) { - // Error - this should never occur + // Unreachable: an address cannot end with both an IP literal and a + // domain set. A 500k-input fuzz confirmed this branch is dead; + // kept as defensive depth. $ctx->invalid = true; $ctx->invalidReason = 'Confusion during parsing'; $ctx->invalidReasonCode = Err::ParserConfusion; $this->log('error', "Email\\Parse->addAddress - both an IP address '{$ctx->ip}' and a domain '{$ctx->domain}' found for the email address '{$ctx->originalAddress}'\n"); + // @codeCoverageIgnoreEnd } elseif ($ctx->ip) { if (filter_var($ctx->ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) { if ($this->options->validateIpGlobalRange && !$this->validateIpGlobalRange($ctx->ip, FILTER_FLAG_IPV4)) { diff --git a/tests/ParseTest.php b/tests/ParseTest.php index dd3ab66..c86ca06 100644 --- a/tests/ParseTest.php +++ b/tests/ParseTest.php @@ -1790,6 +1790,20 @@ public function testDisplayNameMixesQuotedAndUnquotedWords(): void $this->assertSame('j@example.com', $result->simpleAddress); } + /** + * A quoted word in the *middle* of an unquoted display-name phrase — atext, + * then a quoted-string, then more atext — flushes the quoted run into the + * parsed name (RFC 5322 §3.2.5 phrase = 1*word). + */ + public function testDisplayNameWithMidStringQuotedWord(): void + { + $result = (new Parse(null, ParseOptions::rfc5322()))->parseSingle('John "The Man" Smith '); + + $this->assertFalse($result->invalid); + $this->assertSame('John The Man Smith', $result->nameParsed); + $this->assertSame('j@example.com', $result->simpleAddress); + } + /** * An unquoted local part in NFD form under rfc6531 is NFC-normalized, and the * display form is re-derived from the normalized value (RFC 6532 §3.1). From 602ff457eb802f8ccf63c13e9256f5072fe58b4e Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Fri, 28 Aug 2026 20:50:46 -0700 Subject: [PATCH 14/23] test: cover IP-literal global-range validation Adds an explicit test for validateIpGlobalRange behavior via IP-literal domains (private IPv4 -> IpNotInGlobalRange, link-local IPv6 -> Ipv6NotInGlobalRange, global IPv4 accepted). Behavioral coverage of the global-range path that no test asserted directly. Project coverage 96.95% (up from the ~95.84% base); patch 95.17% (>70% target). Remaining uncovered changed-lines are version-conditional (PHP 8.1 IP-range fallback) or defensive branches preempted by earlier checks. --- tests/ParseTest.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/ParseTest.php b/tests/ParseTest.php index c86ca06..f68b0d1 100644 --- a/tests/ParseTest.php +++ b/tests/ParseTest.php @@ -1790,6 +1790,26 @@ public function testDisplayNameMixesQuotedAndUnquotedWords(): void $this->assertSame('j@example.com', $result->simpleAddress); } + /** + * IP-literal domains are validated against the global range when + * validateIpGlobalRange is on (rfc5321): private/reserved IPv4 and IPv6 + * literals are rejected, a globally-routable one is accepted. + */ + public function testIpLiteralGlobalRangeValidation(): void + { + $parser = new Parse(null, ParseOptions::rfc5321()); + + $this->assertSame( + \Email\ParseErrorCode::IpNotInGlobalRange, + $parser->parseSingle('user@[192.168.1.1]')->invalidReasonCode, + ); + $this->assertSame( + \Email\ParseErrorCode::Ipv6NotInGlobalRange, + $parser->parseSingle('user@[IPv6:fe80::1]')->invalidReasonCode, + ); + $this->assertFalse($parser->parseSingle('user@[8.8.8.8]')->invalid); + } + /** * A quoted word in the *middle* of an unquoted display-name phrase — atext, * then a quoted-string, then more atext — flushes the quoted run into the From 95f2aa3ae9b1c055e6d2a984d7678c8e2a7ee773 Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Sun, 30 Aug 2026 09:59:37 -0700 Subject: [PATCH 15/23] ci: re-trigger full pipeline (fresh codecov + Scrutinizer report) From 5561927dd84855c5bd4c9a15f3e7713b66994cbd Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Sun, 30 Aug 2026 22:03:14 -0700 Subject: [PATCH 16/23] review: deprecate redundant getters; fix stale comment; neutral coverage seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staff+ review follow-ups (PR #72), all low-severity: 1. Deprecate the five ParseOptions pass-through getters (getBannedChars, getSeparators, getUseWhitespaceAsSeparator, getLengthLimits, getAllowedWhitespace) now that the fields are public readonly — they duplicate the properties. Read the property; removed in 5.0. Internal call sites repointed to the properties; @psalm-suppress PossiblyUnusedMethod on the now-caller-less public accessors. getMax*Length() stay (they read into $lengthLimits). CHANGELOG/ROADMAP/UPGRADE updated. 2. Fix stale comment: the per-parse ParseContext "keeps the parser reentrant", not "keeps parse() reentrant" — reentrancy lives in parseInternal() now. 3. Coverage seed: switch the pinned SEED 12345 -> 1 and reword the comment. The value is now chosen for reproducibility only, not to maximize the number; SEED=1 still lands at 96.53% (above the 95.84% base) purely from the real tests + @codeCoverageIgnore, so no metric-gaming. 116 tests / 7222 assertions, PHPStan L8, Psalm (fresh), CS all green. --- .github/workflows/ci.yml | 12 ++++++------ CHANGELOG.md | 1 + ROADMAP.md | 2 ++ UPGRADE.md | 9 +++++++++ src/Parse.php | 14 +++++++------- src/ParseOptions.php | 31 ++++++++++++++++++++++++++++--- 6 files changed, 53 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab0cefa..7e7f75f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,13 +82,13 @@ jobs: run: composer install --prefer-dist --no-progress --no-suggest - name: Run test suite with coverage - # Pin SEED so coverage is deterministic: PropertyTest fuzzes from a - # time-based seed by default, which makes the measured line count (and - # thus the Codecov delta) jitter run-to-run. A fixed seed gives a - # reproducible, comparable number. The 8.1–8.6 test jobs stay unseeded - # so they keep fuzzing across the matrix. + # 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: '12345' + SEED: '1' run: bin/phpunit --coverage-clover=coverage.xml --coverage-text - name: Upload coverage to Codecov diff --git a/CHANGELOG.md b/CHANGELOG.md index 8375901..d35a957 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Deprecated - **`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`. diff --git a/ROADMAP.md b/ROADMAP.md index b32c354..33dffc6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -35,6 +35,7 @@ below as a record; planned work follows. - **v3.9 → removed v4.0:** `protected Parse::validateLocalPart(array $emailAddress)` was deprecated in 3.9 and is removed in 4.0 (now a `private` `ParseContext`-based method). Validation is customized via `ParseOptions`. - **v4.0:** `Parse::parse()` (the polymorphic array API) marked `@deprecated` — kept as a working shim over the typed methods; removal targeted for v5.0. - **v4.0:** `Parse::getInstance()` (default-options singleton) marked `@deprecated` — use `new Parse($logger, $options)`; removal targeted for v5.0. +- **v4.0:** `ParseOptions` pass-through getters (`getBannedChars`, `getSeparators`, `getUseWhitespaceAsSeparator`, `getLengthLimits`, `getAllowedWhitespace`) marked `@deprecated` — read the `public readonly` property instead; removal targeted for v5.0. (The `getMax*Length()` helpers stay — they read into `$lengthLimits`.) - `RfcMode` never shipped (existed only on a feature branch). ### Community & documentation @@ -128,6 +129,7 @@ The highest-leverage post-4.0 work: it unlocks i18n, framework-native localizati - [ ] Remove the deprecated `parse()` method (deprecated in 4.0). `parseSingle()` / `parseMultiple()` / `parseStream()` are the entry points; the private `parseInternal()` core stays. - [ ] Remove the deprecated `Parse::getInstance()` singleton (deprecated in 4.0). Use `new Parse($logger, $options)`. +- [ ] Remove the deprecated `ParseOptions` pass-through getters (deprecated in 4.0). Read the `public readonly` properties instead. _(RFC 6854 group syntax moved to 4.2/4.3 — it can be added additively, see above; only a structural redesign of `emailAddresses` would make it a 5.0 break.)_ diff --git a/UPGRADE.md b/UPGRADE.md index f2dabcb..9c39dca 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -77,6 +77,15 @@ $parser = new Parse(); // default options $parser = new Parse(null, ParseOptions::rfc5322()); // configured ``` +#### 3. `ParseOptions` pass-through getters + +Now that the state fields are `public readonly`, `getBannedChars()`, `getSeparators()`, `getUseWhitespaceAsSeparator()`, `getLengthLimits()`, and `getAllowedWhitespace()` just duplicate the properties and are deprecated — read the property instead. (`getMaxLocalPartLength()` etc. are **not** deprecated; they read into `$lengthLimits`.) + +```php +$options->getBannedChars(); // → $options->bannedChars +$options->getLengthLimits(); // → $options->lengthLimits +``` + ### Internal Changes (No Action Needed) These are implementation details behind `@internal` and do not affect callers: the `Parse::STATE_*` constants became a `ParserState` enum, and the internal `ParseContext` accumulator was modernized (camelCase fields, readonly input snapshot/config). They are listed only for completeness — if your code reached into these, it was relying on unsupported internals. diff --git a/src/Parse.php b/src/Parse.php index e6f359b..a40ddad 100644 --- a/src/Parse.php +++ b/src/Parse.php @@ -294,13 +294,13 @@ private function parseInternal(string $emails, bool $multiple, string $encoding) // Whitespace treated as insignificant (folding/separators; trimmable). In // single-address mode CR and LF are excluded — a lone addr-spec has no line // endings — unless trimSingleAddressWhitespace opts back into liberal trimming. - $allowedWhitespace = $this->options->getAllowedWhitespace(); + $allowedWhitespace = $this->options->allowedWhitespace; if (!$multiple && !$this->options->trimSingleAddressWhitespace) { unset($allowedWhitespace["\r"], $allowedWhitespace["\n"]); } // Per-parse accumulator. A fresh instance (never an instance property) - // keeps parse() reentrant across a localPartNormalizer callback. The + // keeps the parser reentrant across a localPartNormalizer callback. The // constructor takes the initial state (STATE_TRIM) and sub-state // (STATE_START) plus the immutable input snapshot + hoisted config, which // it exposes as readonly properties — so no handler can mutate config, and @@ -313,9 +313,9 @@ private function parseInternal(string $emails, bool $multiple, string $encoding) $len, $multiple, $emails, - $this->options->getSeparators(), - $this->options->getBannedChars(), - $this->options->getUseWhitespaceAsSeparator(), + $this->options->separators, + $this->options->bannedChars, + $this->options->useWhitespaceAsSeparator, $allowedWhitespace, ); @@ -1360,7 +1360,7 @@ private function addAddress( // RFC 5321 §4.5.3.1: all limits are in octets (bytes), not characters. // For quoted local-parts the wire form adds 2 DQUOTE bytes to the length. if (!$ctx->invalid && $this->options->enforceLengthLimits) { - $limits = $this->options->getLengthLimits(); + $limits = $this->options->lengthLimits; // RFC 5321 §4.5.3.1.1: local-part max 64 octets (wire form includes DQUOTE for quoted strings) $localPartWireLen = $ctx->localPartQuoted ? strlen($ctx->localPartParsed) + 2 @@ -1656,7 +1656,7 @@ private function validateDomainName(string $domain): array // Labels are guaranteed non-empty: the state machine rejects consecutive // and edge dots (ConsecutiveDots) before the domain validator runs. $parts = explode('.', $domain); - $maxLabelLen = $this->options->getLengthLimits()->maxDomainLabelLength; + $maxLabelLen = $this->options->lengthLimits->maxDomainLabelLength; foreach ($parts as $part) { if (strlen($part) > $maxLabelLen) { return ['valid' => false, 'reason' => "Domain name part '{$part}' must be less than {$maxLabelLen} octets", 'code' => Err::DomainLabelTooLong]; diff --git a/src/ParseOptions.php b/src/ParseOptions.php index 7c48ad4..a9cb380 100644 --- a/src/ParseOptions.php +++ b/src/ParseOptions.php @@ -109,7 +109,11 @@ public function __construct( $this->allowedWhitespace = $whitespaceMap; } - /** @return array */ + /** + * @deprecated 4.0 Read the public readonly `$allowedWhitespace` property directly. Removed in 5.0. + * @psalm-suppress PossiblyUnusedMethod Public BC accessor; internal code reads the property. + * @return array + */ public function getAllowedWhitespace(): array { return $this->allowedWhitespace; @@ -443,24 +447,45 @@ private function cloneWith(array $overrides): self } // ===== Accessors for the state fields ===== + // + // The four getters below duplicate the public readonly properties they + // return; they are @deprecated in 4.0 (read the property) and removed in 5.0. + // The getMax*Length() helpers further down are not duplicates — they reach + // into $lengthLimits — and remain supported. - /** @return array */ + /** + * @deprecated 4.0 Read the public readonly `$bannedChars` property directly. Removed in 5.0. + * @psalm-suppress PossiblyUnusedMethod Public BC accessor; internal code reads the property. + * @return array + */ public function getBannedChars(): array { return $this->bannedChars; } - /** @return array */ + /** + * @deprecated 4.0 Read the public readonly `$separators` property directly. Removed in 5.0. + * @psalm-suppress PossiblyUnusedMethod Public BC accessor; internal code reads the property. + * @return array + */ public function getSeparators(): array { return $this->separators; } + /** + * @deprecated 4.0 Read the public readonly `$useWhitespaceAsSeparator` property directly. Removed in 5.0. + * @psalm-suppress PossiblyUnusedMethod Public BC accessor; internal code reads the property. + */ public function getUseWhitespaceAsSeparator(): bool { return $this->useWhitespaceAsSeparator; } + /** + * @deprecated 4.0 Read the public readonly `$lengthLimits` property directly. Removed in 5.0. + * @psalm-suppress PossiblyUnusedMethod Public BC accessor; internal code reads the property. + */ public function getLengthLimits(): LengthLimits { return $this->lengthLimits; From 0b79142a502cac3e750f6551ccbbbbfe584940bc Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Sun, 30 Aug 2026 22:34:54 -0700 Subject: [PATCH 17/23] test: pin parse() == parseSingle/parseMultiple()->toArray() (oracle) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staff+ review #2 follow-up. When the spec fixtures were rerouted off the deprecated parse() onto the typed API's ->toArray(), two gaps opened: parse() (the raw-array BC contract, live until 5.0) lost its broad coverage, and the fixtures no longer directly assert that ->toArray() matches parse()'s raw array — so a future key drop/rename in toArray() could diverge unnoticed. Add an explicit equivalence test across single/multiple x valid/invalid, name-addr, IP-literal, and comment cases. It restores parse() coverage in both modes and fails the moment the two public shapes diverge. No correctness bugs found in the review; this closes the one genuine gap. 117 tests / 7202 assertions, PHPStan L8, Psalm, CS green. --- tests/ParseTest.php | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/ParseTest.php b/tests/ParseTest.php index f68b0d1..2943d34 100644 --- a/tests/ParseTest.php +++ b/tests/ParseTest.php @@ -1730,6 +1730,40 @@ public function testParserIsReentrantAcrossLocalPartNormalizer(): void $this->assertSame('nested.example.org', $innerResult->domain); } + /** + * The deprecated raw-array parse() must stay byte-identical to the typed + * API's ->toArray(). The spec fixtures now drive the typed path, so this is + * the oracle that guards the legacy array contract (until parse()'s 5.0 + * removal) and pins parse() == parseSingle()/parseMultiple()->toArray(). + */ + public function testDeprecatedParseEqualsTypedToArray(): void + { + $parser = new Parse(null, ParseOptions::rfc5322()); + + // Single mode: valid, invalid, name-addr, IP-literal, comment. + foreach ([ + '"J Doe" ', + 'not-an-email', + 'plain@example.com', + 'ip@[8.8.8.8]', + 'c@example.com (note)', + ] as $in) { + $this->assertSame( + $parser->parse($in, false), + $parser->parseSingle($in)->toArray(), + "parse('{$in}', false) must equal parseSingle()->toArray()", + ); + } + + // Multiple mode: a mixed batch (valid + invalid + name-addr). + $batch = 'a@a.com, bad@, "Q" '; + $this->assertSame( + $parser->parse($batch, true), + $parser->parseMultiple($batch)->toArray(), + 'parse(batch, true) must equal parseMultiple()->toArray()', + ); + } + /** * getInstance() is deprecated (removed in 5.0) but still a working public * method: it returns a shared Parse configured with default options. From a698af03c3783811abd0f8d4d4b3c222fbbc3c08 Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Sun, 30 Aug 2026 23:21:13 -0700 Subject: [PATCH 18/23] review: LoggerAwareInterface; deprecate setOptions; suppress unused-method noise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Principal-review follow-ups (PR #72). A. The 4.0 immutability/DI thesis was self-contradicting: ParseOptions was made immutable and getInstance() deprecated as global-mutable-state, yet Parse still let you swap its whole config on a live instance. - Parse now implements Psr\Log\LoggerAwareInterface; setLogger() returns void (was fluent) — standard PSR-3 logger injection. BREAKING for chained calls. - Deprecate Parse::setOptions() (config should be constructor-injected / immutable); removed in 5.0, at which point $options/$logger go readonly. - CHANGELOG/UPGRADE document both. B. Stop fighting Psalm one method at a time: findUnusedCode flagged every public/BC method with no internal caller (the parse() shim, the 5 deprecated getters, ...) as PossiblyUnusedMethod. Suppress PossiblyUnusedMethod (and MissingOverrideAttribute — #[\Override] is 8.3+, we target 8.1+) at the config level; remove the 6 inline @psalm-suppress annotations. This pruned the psalm baseline from 47 entries to 0. C. Roadmap: setOptions removal + immutable Parse in v5.0; and two long-term god-object items (the 27-param ParseOptions constructor; the ~1,700-LOC Parse class) recorded in the backlog. 117 tests / 7260 assertions, PHPStan L8, Psalm (fresh, empty baseline), CS green. --- CHANGELOG.md | 2 ++ ROADMAP.md | 4 +++ UPGRADE.md | 27 +++++++++++++++++ psalm-baseline.xml | 72 +------------------------------------------- psalm.xml | 12 ++++++++ src/Parse.php | 21 ++++++------- src/ParseOptions.php | 5 --- tests/ParseTest.php | 14 ++++++--- 8 files changed, 65 insertions(+), 92 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d35a957..dec9b24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Changed - **`ParseOptions` state fields are now `public readonly`** — `bannedChars`, `separators`, `useWhitespaceAsSeparator`, `lengthLimits`, and `allowedWhitespace` are readable directly as properties (the existing `getX()` accessors remain). 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 +- **`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`. diff --git a/ROADMAP.md b/ROADMAP.md index 33dffc6..88b3e6a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -36,6 +36,7 @@ below as a record; planned work follows. - **v4.0:** `Parse::parse()` (the polymorphic array API) marked `@deprecated` — kept as a working shim over the typed methods; removal targeted for v5.0. - **v4.0:** `Parse::getInstance()` (default-options singleton) marked `@deprecated` — use `new Parse($logger, $options)`; removal targeted for v5.0. - **v4.0:** `ParseOptions` pass-through getters (`getBannedChars`, `getSeparators`, `getUseWhitespaceAsSeparator`, `getLengthLimits`, `getAllowedWhitespace`) marked `@deprecated` — read the `public readonly` property instead; removal targeted for v5.0. (The `getMax*Length()` helpers stay — they read into `$lengthLimits`.) +- **v4.0:** `Parse::setOptions()` marked `@deprecated` — pass options to the constructor; a parser's configuration should be immutable for the life of the instance. Removal targeted for v5.0. (`Parse::setLogger()` now returns `void` via `LoggerAwareInterface` — a 4.0 breaking change, not a deprecation.) - `RfcMode` never shipped (existed only on a feature branch). ### Community & documentation @@ -130,11 +131,14 @@ The highest-leverage post-4.0 work: it unlocks i18n, framework-native localizati - [ ] Remove the deprecated `parse()` method (deprecated in 4.0). `parseSingle()` / `parseMultiple()` / `parseStream()` are the entry points; the private `parseInternal()` core stays. - [ ] Remove the deprecated `Parse::getInstance()` singleton (deprecated in 4.0). Use `new Parse($logger, $options)`. - [ ] Remove the deprecated `ParseOptions` pass-through getters (deprecated in 4.0). Read the `public readonly` properties instead. +- [ ] Remove the deprecated `Parse::setOptions()` (deprecated in 4.0) and make `Parse`'s `$options`/`$logger` `readonly` — a parser instance's configuration becomes immutable after construction, completing the immutability/DI direction begun in 4.0. _(RFC 6854 group syntax moved to 4.2/4.3 — it can be added additively, see above; only a structural redesign of `emailAddresses` would make it a 5.0 break.)_ ### Backlog (unversioned) +- [ ] **`ParseOptions` has a 27-parameter constructor** (principal-review finding). Usable today via named arguments + preset factories + `withX()` builders, but at the edge of maintainability. Group related flags into cohesive value objects (e.g. `LocalPartRules` / `DomainRules`) — breaking, so a v5.0 candidate. +- [ ] **`Parse` is a ~1,700-LOC god-class** (principal-review finding) carrying the state machine, all validation, IDN/punycode conversion, NFC normalization, and output assembly. Extract the validation and IDN/normalization concerns into collaborators; the per-state handler split (below) is the first step. - [ ] **`parse()` refactor & modernization follow-ups** (from review; non-blocking, each behavior-preserving and test-gated): - [x] Renamed `ParseContext`'s accumulator fields `snake_case` → `camelCase` to match the codebase. Output-array keys stay `snake_case` (public API, string literals in `addAddress()`); only the internal properties changed. - [x] **Encoded `ParseContext`'s three concerns structurally.** The input snapshot (`chars`/`len`/`emails`) and hoisted config (`separators`, `bannedChars`, …) are now `public readonly` constructor-promoted properties, so only the per-address accumulator stays mutable — a handler can no longer write config. `parseInternal()`'s setup was reordered to build the config before constructing the context. diff --git a/UPGRADE.md b/UPGRADE.md index 9c39dca..4c7761f 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -42,6 +42,22 @@ The `getX()` accessors (`getBannedChars()`, `getSeparators()`, `getLengthLimits( These took the parser's internal accumulator and were never a documented extension point. If you subclassed `Parse` to override either, move that logic to `ParseOptions` configuration (rule properties, or the `withLocalPartNormalizer()` callback). `validateLocalPart()`'s brief `array`-signature deprecation window in 3.9 is now closed. +#### 3. `Parse::setLogger()` returns `void` + +`Parse` now implements `Psr\Log\LoggerAwareInterface`, so `setLogger()` returns `void` instead of `$this`. This is the standard PSR-3 pattern (DI containers can auto-inject the logger). Only affects you if you *chained* on it: + +```php +// Before (chained) +$parser->setLogger($logger)->parseSingle($email); + +// After — two statements +$parser->setLogger($logger); +$parser->parseSingle($email); + +// Or inject at construction (preferred) +$parser = new Parse($logger, $options); +``` + ### Deprecated (Still Functional) Both keep working in the entire 4.x line and are removed in **5.0**. @@ -86,6 +102,17 @@ $options->getBannedChars(); // → $options->bannedChars $options->getLengthLimits(); // → $options->lengthLimits ``` +#### 4. `Parse::setOptions()` + +Deprecated — a parser's configuration should be fixed for the life of the instance (mutating it on a shared parser is a footgun). Pass options to the constructor instead: + +```php +// Before +$parser->setOptions(ParseOptions::rfc5322()); +// After +$parser = new Parse(null, ParseOptions::rfc5322()); +``` + ### Internal Changes (No Action Needed) These are implementation details behind `@internal` and do not affect callers: the `Parse::STATE_*` constants became a `ParserState` enum, and the internal `ParseContext` accumulator was modernized (camelCase fields, readonly input snapshot/config). They are listed only for completeness — if your code reached into these, it was relying on unsupported internals. diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 37e860c..c67b56a 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,72 +1,2 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/psalm.xml b/psalm.xml index 9ca84db..cfc99a0 100644 --- a/psalm.xml +++ b/psalm.xml @@ -15,4 +15,16 @@ + + + + + + + diff --git a/src/Parse.php b/src/Parse.php index a40ddad..de126ec 100644 --- a/src/Parse.php +++ b/src/Parse.php @@ -3,12 +3,13 @@ namespace Email; use Email\ParseErrorCode as Err; +use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerInterface; /** * Class Parse. */ -class Parse +class Parse implements LoggerAwareInterface { // The state-machine states are the {@see ParserState} enum (formerly // Parse::STATE_* constants). @@ -67,17 +68,19 @@ public function __construct( } /** - * Allows for post-construct injection of a logger. - * - * @param LoggerInterface $logger PSR-3 compliant logger + * Inject a PSR-3 logger post-construction, per {@see LoggerAwareInterface}. + * (A logger may also be passed to the constructor.) */ - public function setLogger(LoggerInterface $logger): Parse + public function setLogger(LoggerInterface $logger): void { $this->logger = $logger; - - return $this; } + /** + * @deprecated 4.0 Pass options to the constructor — a parser's configuration + * should be immutable for the life of the instance; mutating it on + * a shared parser is a footgun. Removed in 5.0. + */ public function setOptions(ParseOptions $options): Parse { $this->options = $options; @@ -205,10 +208,6 @@ private function validateIpGlobalRange(string $ip, int $ipType): bool * 'invalid' => boolean, 'invalid_reason' => string|null, * 'invalid_reason_code' => ParseErrorCode|null, 'comments' => array) * endif; - * - * @psalm-suppress PossiblyUnusedMethod Deprecated public API — the typed - * methods use parseInternal() directly, so nothing internal calls this, - * but external code still does until its 5.0 removal. */ public function parse(string $emails, bool $multiple = true, string $encoding = 'UTF-8'): array { diff --git a/src/ParseOptions.php b/src/ParseOptions.php index a9cb380..153c9c3 100644 --- a/src/ParseOptions.php +++ b/src/ParseOptions.php @@ -111,7 +111,6 @@ public function __construct( /** * @deprecated 4.0 Read the public readonly `$allowedWhitespace` property directly. Removed in 5.0. - * @psalm-suppress PossiblyUnusedMethod Public BC accessor; internal code reads the property. * @return array */ public function getAllowedWhitespace(): array @@ -455,7 +454,6 @@ private function cloneWith(array $overrides): self /** * @deprecated 4.0 Read the public readonly `$bannedChars` property directly. Removed in 5.0. - * @psalm-suppress PossiblyUnusedMethod Public BC accessor; internal code reads the property. * @return array */ public function getBannedChars(): array @@ -465,7 +463,6 @@ public function getBannedChars(): array /** * @deprecated 4.0 Read the public readonly `$separators` property directly. Removed in 5.0. - * @psalm-suppress PossiblyUnusedMethod Public BC accessor; internal code reads the property. * @return array */ public function getSeparators(): array @@ -475,7 +472,6 @@ public function getSeparators(): array /** * @deprecated 4.0 Read the public readonly `$useWhitespaceAsSeparator` property directly. Removed in 5.0. - * @psalm-suppress PossiblyUnusedMethod Public BC accessor; internal code reads the property. */ public function getUseWhitespaceAsSeparator(): bool { @@ -484,7 +480,6 @@ public function getUseWhitespaceAsSeparator(): bool /** * @deprecated 4.0 Read the public readonly `$lengthLimits` property directly. Removed in 5.0. - * @psalm-suppress PossiblyUnusedMethod Public BC accessor; internal code reads the property. */ public function getLengthLimits(): LengthLimits { diff --git a/tests/ParseTest.php b/tests/ParseTest.php index 2943d34..9c1193d 100644 --- a/tests/ParseTest.php +++ b/tests/ParseTest.php @@ -843,18 +843,22 @@ public function testStateFieldsArePublicReadonlyAndConfigurable(): void } /** - * Exercises the fluent and deprecated mutators on the Parse class itself. - * Pre-existing public API covered here for the first time. + * The mutators on the Parse class itself: setOptions() is deprecated but + * still fluent (removed in 5.0); setLogger() implements PSR-3 + * LoggerAwareInterface (returns void). */ - public function testParseSetLoggerAndSetOptionsAreFluent(): void + public function testParseSetOptionsFluentAndSetLoggerIsLoggerAware(): void { $parser = new Parse(); + $this->assertInstanceOf(\Psr\Log\LoggerAwareInterface::class, $parser); + $opts = ParseOptions::rfc5322(); $this->assertSame($parser, $parser->setOptions($opts), 'setOptions() is fluent'); $this->assertSame($opts, $parser->getOptions()); - $logger = new \Psr\Log\NullLogger(); - $this->assertSame($parser, $parser->setLogger($logger), 'setLogger() is fluent'); + // LoggerAwareInterface::setLogger() returns void; the injected logger is used. + $parser->setLogger(new \Psr\Log\NullLogger()); + $this->assertFalse($parser->parseSingle('a@b.com')->invalid); } /** From c6e5d13b34883bd128c06f5222dc8ab1699e1736 Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Tue, 1 Sep 2026 23:32:16 -0700 Subject: [PATCH 19/23] feat: ship Rector migration config for 3.x -> 4.0 call-site upgrades MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds rector/upgrade-4.0.php plus three custom rules that auto-fix the mechanical parts of the 4.0 upgrade (opt-in — the consumer runs it and reviews the diff; nothing runs on composer update): - GetInstanceToNewParseRector: Parse::getInstance() -> new Parse() - ParseOptionsGetterToPropertyRector: $o->getBannedChars() -> $o->bannedChars (+ separators/useWhitespaceAsSeparator/ lengthLimits/allowedWhitespace; getMax* length helpers left alone) - ParseOptionsSetterToWithRector: $o->setBannedChars($v) -> $o = $o->withBannedChars($v) (+ 3 more), only when the receiver is directly assignable Semantic migrations (parse() array->object, setMax*Length, setOptions, chained setLogger) are intentionally left to the documented manual steps. Verified end-to-end: rector applies all three rules correctly to a fixture and leaves the excluded cases untouched. rector/rector added as a dev dep; the rules are linted (cs-fixer) and type-checked (PHPStan) in CI. UPGRADE.md gains an "Automated migration (Rector)" section; CHANGELOG Added entry. 117 tests / 7225 assertions, PHPStan L8, Psalm, CS all green. --- .php-cs-fixer.dist.php | 1 + CHANGELOG.md | 3 + UPGRADE.md | 17 ++++ composer.json | 3 +- phpstan.neon | 1 + rector/rules/GetInstanceToNewParseRector.php | 59 ++++++++++++++ .../ParseOptionsGetterToPropertyRector.php | 72 +++++++++++++++++ .../rules/ParseOptionsSetterToWithRector.php | 81 +++++++++++++++++++ rector/upgrade-4.0.php | 37 +++++++++ 9 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 rector/rules/GetInstanceToNewParseRector.php create mode 100644 rector/rules/ParseOptionsGetterToPropertyRector.php create mode 100644 rector/rules/ParseOptionsSetterToWithRector.php create mode 100644 rector/upgrade-4.0.php diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index c3c3161..b91ca78 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -3,6 +3,7 @@ $finder = PhpCsFixer\Finder::create() ->in(__DIR__ . '/src') ->in(__DIR__ . '/tests') + ->in(__DIR__ . '/rector') ->name('*.php') ->ignoreDotFiles(true) ->ignoreVCS(true); diff --git a/CHANGELOG.md b/CHANGELOG.md index dec9b24..40cb6b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ 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. 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 existing `getX()` accessors remain). 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. diff --git a/UPGRADE.md b/UPGRADE.md index 4c7761f..41a7693 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -6,6 +6,23 @@ v4.0 is a **breaking-modernization** release. Parsing behavior is unchanged — For most callers the upgrade is a mechanical find-and-replace. If you only ever call `parseSingle()` / `parseMultiple()` / `parseStream()` and configure options with the constructor or `withX()` builders, **no changes are required.** +### Automated migration (Rector) + +The mechanical call-site changes can be auto-fixed with the [Rector](https://getrector.com) config shipped in the package. Install Rector if you don't have it, then run the config against your source: + +```bash +composer require --dev rector/rector +vendor/bin/rector process src --config vendor/mmucklo/email-parse/rector/upgrade-4.0.php --dry-run +``` + +Drop `--dry-run` to apply, then **review the diff and commit** (Rector never runs on its own — it's opt-in, and a dependency update will not modify your code). It rewrites: + +- `Parse::getInstance()` → `new Parse()` +- `$options->getBannedChars()` (and the other four pass-through getters) → the `public readonly` property read +- `$options->setBannedChars($v)` (and `setSeparators` / `setUseWhitespaceAsSeparator` / `setLengthLimits`) → `$options = $options->withX($v)` + +It deliberately leaves the **semantic** changes for you to do by hand (they can't be rewritten safely): the `parse()` → `parseSingle()`/`parseMultiple()` migration (the return *shape* changes from array to object), `setMaxLocalPartLength()` etc. (rebuild a `LengthLimits`), `setOptions()` → constructor, and chained `setLogger()`. Those are covered below. + ### Breaking Changes #### 1. `ParseOptions` mutating setters removed diff --git a/composer.json b/composer.json index c4e96d3..6201840 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,8 @@ "symfony/yaml": "^6.4|^7.2", "infection/infection": "^0.29.8", "phpbench/phpbench": "^1.4", - "vimeo/psalm": "^6.0" + "vimeo/psalm": "^6.0", + "rector/rector": "^2.0" }, "require": { "php": "^8.1", diff --git a/phpstan.neon b/phpstan.neon index 9cf31c4..092585f 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -6,6 +6,7 @@ parameters: paths: - src - tests + - rector excludePaths: - vendor reportUnmatchedIgnoredErrors: false diff --git a/rector/rules/GetInstanceToNewParseRector.php b/rector/rules/GetInstanceToNewParseRector.php new file mode 100644 index 0000000..311749e --- /dev/null +++ b/rector/rules/GetInstanceToNewParseRector.php @@ -0,0 +1,59 @@ +parseSingle($email);', + '(new Parse())->parseSingle($email);', + ), + ], + ); + } + + /** + * @return array> + */ + public function getNodeTypes(): array + { + return [StaticCall::class]; + } + + public function refactor(Node $node): ?Node + { + /** @var StaticCall $node */ + if (!$this->isName($node->class, 'Email\Parse')) { + return null; + } + + if (!$this->isName($node->name, 'getInstance')) { + return null; + } + + return new New_(new FullyQualified('Email\Parse')); + } +} diff --git a/rector/rules/ParseOptionsGetterToPropertyRector.php b/rector/rules/ParseOptionsGetterToPropertyRector.php new file mode 100644 index 0000000..a5d9d7d --- /dev/null +++ b/rector/rules/ParseOptionsGetterToPropertyRector.php @@ -0,0 +1,72 @@ + getter method => readonly property */ + private const GETTER_TO_PROPERTY = [ + 'getBannedChars' => 'bannedChars', + 'getSeparators' => 'separators', + 'getUseWhitespaceAsSeparator' => 'useWhitespaceAsSeparator', + 'getLengthLimits' => 'lengthLimits', + 'getAllowedWhitespace' => 'allowedWhitespace', + ]; + + public function getRuleDefinition(): RuleDefinition + { + return new RuleDefinition( + 'Read the ParseOptions public readonly property instead of its deprecated getter', + [ + new CodeSample( + '$options->getBannedChars();', + '$options->bannedChars;', + ), + ], + ); + } + + /** + * @return array> + */ + public function getNodeTypes(): array + { + return [MethodCall::class]; + } + + public function refactor(Node $node): ?Node + { + /** @var MethodCall $node */ + if ($node->isFirstClassCallable() || $node->getArgs() !== []) { + return null; + } + + $method = $this->getName($node->name); + if ($method === null || !isset(self::GETTER_TO_PROPERTY[$method])) { + return null; + } + + if (!$this->isObjectType($node->var, new ObjectType('Email\ParseOptions'))) { + return null; + } + + return new PropertyFetch($node->var, self::GETTER_TO_PROPERTY[$method]); + } +} diff --git a/rector/rules/ParseOptionsSetterToWithRector.php b/rector/rules/ParseOptionsSetterToWithRector.php new file mode 100644 index 0000000..a66ad4b --- /dev/null +++ b/rector/rules/ParseOptionsSetterToWithRector.php @@ -0,0 +1,81 @@ +setBannedChars($x)` becomes `$o = $o->withBannedChars($x)`. + * Only applies when the receiver is directly assignable (a variable or property); + * anything more complex is left for manual migration. + * + * The setMax*Length() setters are intentionally excluded — their replacement builds + * a new LengthLimits from the other two limits, which needs human context. + */ +final class ParseOptionsSetterToWithRector extends AbstractRector +{ + /** @var array mutating setter => immutable builder */ + private const SETTER_TO_WITH = [ + 'setBannedChars' => 'withBannedChars', + 'setSeparators' => 'withSeparators', + 'setUseWhitespaceAsSeparator' => 'withUseWhitespaceAsSeparator', + 'setLengthLimits' => 'withLengthLimits', + ]; + + public function getRuleDefinition(): RuleDefinition + { + return new RuleDefinition( + 'Replace a removed ParseOptions setter with its withX() builder, re-assigning the result', + [ + new CodeSample( + '$options->setBannedChars($chars);', + '$options = $options->withBannedChars($chars);', + ), + ], + ); + } + + /** + * @return array> + */ + public function getNodeTypes(): array + { + return [MethodCall::class]; + } + + public function refactor(Node $node): ?Node + { + /** @var MethodCall $node */ + $method = $this->getName($node->name); + if ($method === null || !isset(self::SETTER_TO_WITH[$method])) { + return null; + } + + // Only rewrite when the result can be assigned straight back to the receiver. + if (!$node->var instanceof Variable && !$node->var instanceof PropertyFetch) { + return null; + } + + if (!$this->isObjectType($node->var, new ObjectType('Email\ParseOptions'))) { + return null; + } + + $with = new MethodCall($node->var, self::SETTER_TO_WITH[$method], $node->getArgs()); + + return new Assign($node->var, $with); + } +} diff --git a/rector/upgrade-4.0.php b/rector/upgrade-4.0.php new file mode 100644 index 0000000..4535ea5 --- /dev/null +++ b/rector/upgrade-4.0.php @@ -0,0 +1,37 @@ + 4.0. + * + * Auto-fixes the mechanical call-site changes. Run it against YOUR source: + * + * vendor/bin/rector process src --config vendor/mmucklo/email-parse/rector/upgrade-4.0.php --dry-run + * + * (drop --dry-run to apply, then review the diff and commit). + * + * Covered: + * - Parse::getInstance() -> new Parse() + * - ParseOptions::getX() -> ParseOptions readonly property read + * - ParseOptions::setX($v) -> $o = $o->withX($v) (banned/separators/whitespace/lengthLimits) + * + * NOT covered (semantic changes — migrate by hand, see UPGRADE.md): + * - parse($x, true|false) -> parseMultiple()/parseSingle() (return SHAPE changes: array -> object) + * - setMaxLocalPartLength() etc. -> withLengthLimits(new LengthLimits(...)) (needs the other two limits) + * - Parse::setOptions() -> constructor injection + * - Parse::setLogger()->chain(...) -> split (setLogger() now returns void) + */ + +use Rector\Config\RectorConfig; + +require_once __DIR__ . '/rules/GetInstanceToNewParseRector.php'; +require_once __DIR__ . '/rules/ParseOptionsGetterToPropertyRector.php'; +require_once __DIR__ . '/rules/ParseOptionsSetterToWithRector.php'; + +return RectorConfig::configure() + ->withRules([ + \Email\Rector\GetInstanceToNewParseRector::class, + \Email\Rector\ParseOptionsGetterToPropertyRector::class, + \Email\Rector\ParseOptionsSetterToWithRector::class, + ]); From 8c02b332df08f2b2df064bbf125633613eaf97ec Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Tue, 1 Sep 2026 23:51:44 -0700 Subject: [PATCH 20/23] feat: emit runtime deprecation notices; add composer support metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the deprecate -> warn -> auto-fix story. - Every deprecated public method (parse(), getInstance(), setOptions(), and the five ParseOptions pass-through getters) now calls trigger_deprecation() from symfony/deprecation-contracts, so callers get an E_USER_DEPRECATED at runtime — not just a docblock. symfony/phpunit-bridge and similar tools aggregate these into a call-site report; the shipped Rector config then auto-fixes most. - Add symfony/deprecation-contracts to require (a tiny, standard, zero-dep package; the ecosystem-idiomatic way to signal deprecations). - phpunit.xml: convertDeprecationsToExceptions="false" so the suite's own intentional deprecated-API tests emit notices without failing. - New test asserts each deprecated method fires a notice naming it. - composer.json: add "support" (issues/source/docs) so Packagist surfaces the UPGRADE guide. (Composer has no native auto-run/warn-on-update hook; runtime deprecations are the correct signal.) 118 tests / 7229 assertions, PHPStan L8, Psalm, CS all green. --- CHANGELOG.md | 1 + composer.json | 8 +++++++- phpunit.xml | 2 +- src/Parse.php | 6 ++++++ src/ParseOptions.php | 10 +++++++++ tests/ParseTest.php | 49 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 74 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40cb6b0..d2ce64d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **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. diff --git a/composer.json b/composer.json index 6201840..9fd94eb 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,11 @@ "email": "mmucklo@gmail.com" } ], + "support": { + "issues": "https://github.com/mmucklo/email-parse/issues", + "source": "https://github.com/mmucklo/email-parse", + "docs": "https://github.com/mmucklo/email-parse/blob/master/UPGRADE.md" + }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.65", "phpstan/phpstan": "^2.0", @@ -36,7 +41,8 @@ "php": "^8.1", "ext-mbstring": "*", "psr/log": "^3.0", - "symfony/polyfill-intl-idn": "^1.31" + "symfony/polyfill-intl-idn": "^1.31", + "symfony/deprecation-contracts": "^3.0" }, "autoload": { "psr-4": { diff --git a/phpunit.xml b/phpunit.xml index 966df8e..aea0da9 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,5 +1,5 @@ - + ./src/ diff --git a/src/Parse.php b/src/Parse.php index de126ec..276f1f2 100644 --- a/src/Parse.php +++ b/src/Parse.php @@ -46,6 +46,8 @@ class Parse implements LoggerAwareInterface */ public static function getInstance(): Parse { + trigger_deprecation('mmucklo/email-parse', '4.0', 'Parse::getInstance() is deprecated, use "new Parse($logger, $options)" instead. It is removed in 5.0.'); + if (!self::$instance) { return self::$instance = new self(); } @@ -83,6 +85,8 @@ public function setLogger(LoggerInterface $logger): void */ public function setOptions(ParseOptions $options): Parse { + trigger_deprecation('mmucklo/email-parse', '4.0', 'Parse::setOptions() is deprecated, pass options to the constructor instead — a parser\'s configuration should be immutable. It is removed in 5.0.'); + $this->options = $options; return $this; @@ -211,6 +215,8 @@ private function validateIpGlobalRange(string $ip, int $ipType): bool */ public function parse(string $emails, bool $multiple = true, string $encoding = 'UTF-8'): array { + trigger_deprecation('mmucklo/email-parse', '4.0', 'Parse::parse() is deprecated, use parseSingle()/parseMultiple() (or parseStream()) and ->toArray() if you need the array shape. It is removed in 5.0.'); + return $this->parseInternal($emails, $multiple, $encoding); } diff --git a/src/ParseOptions.php b/src/ParseOptions.php index 153c9c3..12c62f2 100644 --- a/src/ParseOptions.php +++ b/src/ParseOptions.php @@ -115,6 +115,8 @@ public function __construct( */ public function getAllowedWhitespace(): array { + trigger_deprecation('mmucklo/email-parse', '4.0', 'ParseOptions::getAllowedWhitespace() is deprecated, read the $allowedWhitespace property instead. It is removed in 5.0.'); + return $this->allowedWhitespace; } @@ -458,6 +460,8 @@ private function cloneWith(array $overrides): self */ public function getBannedChars(): array { + trigger_deprecation('mmucklo/email-parse', '4.0', 'ParseOptions::getBannedChars() is deprecated, read the $bannedChars property instead. It is removed in 5.0.'); + return $this->bannedChars; } @@ -467,6 +471,8 @@ public function getBannedChars(): array */ public function getSeparators(): array { + trigger_deprecation('mmucklo/email-parse', '4.0', 'ParseOptions::getSeparators() is deprecated, read the $separators property instead. It is removed in 5.0.'); + return $this->separators; } @@ -475,6 +481,8 @@ public function getSeparators(): array */ public function getUseWhitespaceAsSeparator(): bool { + trigger_deprecation('mmucklo/email-parse', '4.0', 'ParseOptions::getUseWhitespaceAsSeparator() is deprecated, read the $useWhitespaceAsSeparator property instead. It is removed in 5.0.'); + return $this->useWhitespaceAsSeparator; } @@ -483,6 +491,8 @@ public function getUseWhitespaceAsSeparator(): bool */ public function getLengthLimits(): LengthLimits { + trigger_deprecation('mmucklo/email-parse', '4.0', 'ParseOptions::getLengthLimits() is deprecated, read the $lengthLimits property instead. It is removed in 5.0.'); + return $this->lengthLimits; } diff --git a/tests/ParseTest.php b/tests/ParseTest.php index 9c1193d..31e1ce4 100644 --- a/tests/ParseTest.php +++ b/tests/ParseTest.php @@ -1875,4 +1875,53 @@ public function testUnquotedLocalPartIsNfcNormalized(): void $this->assertFalse($result->invalid); $this->assertSame("caf\u{00E9}", $result->localPartParsed); } + + /** + * Every deprecated public method emits a runtime E_USER_DEPRECATED notice + * (via symfony/deprecation-contracts' trigger_deprecation), so callers see + * it and tools like symfony/phpunit-bridge can aggregate it. + */ + public function testDeprecatedMethodsTriggerRuntimeDeprecations(): void + { + $seen = []; + set_error_handler(static function (int $errno, string $message) use (&$seen): bool { + if (E_USER_DEPRECATED === $errno) { + $seen[] = $message; + } + + return true; // handled — don't propagate + }); + + try { + $opts = ParseOptions::rfc5322(); + $parser = new Parse(null, $opts); + + $parser->parse('a@b.com', false); + Parse::getInstance(); + $parser->setOptions($opts); + $opts->getBannedChars(); + $opts->getSeparators(); + $opts->getUseWhitespaceAsSeparator(); + $opts->getLengthLimits(); + $opts->getAllowedWhitespace(); + } finally { + restore_error_handler(); + } + + $joined = implode("\n", $seen); + foreach ([ + 'Parse::parse()', + 'Parse::getInstance()', + 'Parse::setOptions()', + 'ParseOptions::getBannedChars()', + 'ParseOptions::getSeparators()', + 'ParseOptions::getUseWhitespaceAsSeparator()', + 'ParseOptions::getLengthLimits()', + 'ParseOptions::getAllowedWhitespace()', + ] as $needle) { + $this->assertStringContainsString($needle, $joined, "expected a deprecation naming {$needle}"); + } + // trigger_deprecation prefixes the package + version. + $this->assertStringContainsString('Since mmucklo/email-parse 4.0:', $joined); + } } From 4c5e9a3e9c992db5f7634c53e25df74c9db4d6a7 Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Sun, 13 Sep 2026 23:22:30 -0700 Subject: [PATCH 21/23] ci(scrutinizer): coverage only; drop the PHP analyzer php-scrutinizer-run does not model PHP 8.1 readonly properties or enum ->name/->value, so it reported six false-positive "bugs" on 4.0 (readonly assignment inside the declaring constructor; ParserState->name). PHPStan level 8 and Psalm already run in GitHub Actions and accept the same code, so Scrutinizer's analysis node and its checks block are removed. The coverage node stays, with SEED=1 pinned to match the CI coverage job so the reported number is deterministic. --- .scrutinizer.yml | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/.scrutinizer.yml b/.scrutinizer.yml index c778e4d..513bda7 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -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: @@ -7,11 +12,6 @@ filter: - 'tests/*' - 'vendor/*' -checks: - php: - code_rating: true - duplication: true - build: image: default-jammy environment: @@ -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' From 3fa50a4feb92df9b5d632896c3a4c9de6f8ffd92 Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Mon, 14 Sep 2026 04:08:02 -0700 Subject: [PATCH 22/23] review: ownership-aware setter rewrite; document parse() override break; doc fixes Follow-ups from the staff+ review of PR #72. Rector (rector/upgrade-4.0.php): - ParseOptionsSetterToWithRector now tracks receiver ownership per function scope in source order instead of trusting receiver syntax. A variable is owned once assigned from new ParseOptions(...), a ParseOptions::rfc*() preset, or a withX() chain, and stops being owned when it escapes (passed as an argument such as new Parse(null, $o), copied to another variable, captured by a closure, or reassigned from an unknown source such as getOptions()). Parameters are never owned. Owned receivers and the object's own properties are rewritten to `$o = $o->withX()`; everything else is left in place (it fails loudly on 4.0) and annotated with a `TODO email-parse 4.0:` comment. Previously `$o = $parser->getOptions(); $o->setSeparators([';'])` was rewritten into a local reassignment that silently stopped configuring the parser. - ParseOptionsGetterToPropertyRector also handles nullsafe calls (`$o?->getX()` -> `$o?->x`). - New tests/RectorUpgradeTest.php runs the config against a fixture that covers every ownership branch and checks the rewrite is idempotent. tests/fixtures is excluded from PHPStan and CS Fixer (it intentionally calls removed 3.x methods). Docs: - Record the one undocumented break: subclass overrides of parse() no longer affect parseSingle()/parseMultiple()/parseStream(), which now call the private parseInternal() directly (UPGRADE.md #4, CHANGELOG). - UPGRADE.md: fix the parse() migration snippet (->toArray() reproduces the success/reason/email_addresses envelope; the ['email_addresses'] subscript stripped it), and remove the claims that the getX() accessors are unchanged and that only two methods are deprecated. Describe the Rector ownership behaviour and the runtime deprecation notices. - ARCHITECTURE.md: camelCase field names, ParserState-typed resetAddress(), readonly snapshot/config; drop the stale "rename is a follow-up" note. - ParseOptions.php: fix the "will become readonly in v4.0" comment; Parse.php: comments now cite ParserState::X instead of the removed STATE_* constants, and the switch default-arm comment describes what it actually guards (a state/sub-state mix-up) rather than claiming type-level unreachability. Tests: - The runtime-deprecation test's error handler now only swallows E_USER_DEPRECATED; other errno fall through to PHPUnit's converters. 119 tests / 7240 assertions, PHPStan L8, Psalm, CS all green. --- .php-cs-fixer.dist.php | 1 + ARCHITECTURE.md | 13 +- CHANGELOG.md | 5 +- UPGRADE.md | 40 ++- phpstan.neon | 1 + .../ParseOptionsGetterToPropertyRector.php | 14 +- .../rules/ParseOptionsSetterToWithRector.php | 245 ++++++++++++++++-- src/Parse.php | 60 ++--- src/ParseOptions.php | 9 +- tests/ParseTest.php | 7 +- tests/RectorUpgradeTest.php | 56 ++++ .../fixtures/rector/upgrade-4.0.expected.php | 61 +++++ tests/fixtures/rector/upgrade-4.0.input.php | 49 ++++ 13 files changed, 481 insertions(+), 80 deletions(-) create mode 100644 tests/RectorUpgradeTest.php create mode 100644 tests/fixtures/rector/upgrade-4.0.expected.php create mode 100644 tests/fixtures/rector/upgrade-4.0.input.php diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index b91ca78..64c91fc 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -4,6 +4,7 @@ ->in(__DIR__ . '/src') ->in(__DIR__ . '/tests') ->in(__DIR__ . '/rector') + ->exclude('fixtures') ->name('*.php') ->ignoreDotFiles(true) ->ignoreVCS(true); diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 010e262..8ef0bfb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -107,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 diff --git a/CHANGELOG.md b/CHANGELOG.md index d2ce64d..d18c1f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,10 @@ 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. Opt-in (you run it and review the diff); see [UPGRADE.md](UPGRADE.md). +- **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 existing `getX()` accessors remain). Every `ParseOptions` property is now readonly; configure via the constructor or the `withX()` builders. +- **`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 @@ -22,6 +22,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### 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] diff --git a/UPGRADE.md b/UPGRADE.md index 41a7693..c4c24b6 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -2,7 +2,7 @@ ## v3.x → v4.0 -v4.0 is a **breaking-modernization** release. Parsing behavior is unchanged — no changes to parsing logic, error codes, or the output shape — so a valid address parses identically. The breaks are all API-surface cleanup: the long-deprecated mutating setters are gone, `ParseOptions` config is now fully immutable, and two internal methods became `private`. Two public methods are newly deprecated (they still work). +v4.0 is a **breaking-modernization** release. Parsing behavior is unchanged — no changes to parsing logic, error codes, or the output shape — so a valid address parses identically. The breaks are all API-surface cleanup: the long-deprecated mutating setters are gone, `ParseOptions` config is now fully immutable, two internal methods became `private`, `setLogger()` follows PSR-3 `LoggerAwareInterface`, and the typed entry points no longer route through `parse()`. Several public methods are newly deprecated: they still work, but each one now **emits a runtime `E_USER_DEPRECATED` notice** when called (see *Deprecated* below), so a test suite that promotes deprecations to failures will flag them. For most callers the upgrade is a mechanical find-and-replace. If you only ever call `parseSingle()` / `parseMultiple()` / `parseStream()` and configure options with the constructor or `withX()` builders, **no changes are required.** @@ -18,10 +18,12 @@ vendor/bin/rector process src --config vendor/mmucklo/email-parse/rector/upgrade Drop `--dry-run` to apply, then **review the diff and commit** (Rector never runs on its own — it's opt-in, and a dependency update will not modify your code). It rewrites: - `Parse::getInstance()` → `new Parse()` -- `$options->getBannedChars()` (and the other four pass-through getters) → the `public readonly` property read -- `$options->setBannedChars($v)` (and `setSeparators` / `setUseWhitespaceAsSeparator` / `setLengthLimits`) → `$options = $options->withX($v)` +- `$options->getBannedChars()` (and the other four pass-through getters, including nullsafe `$options?->getX()`) → the `public readonly` property read +- `$options->setBannedChars($v)` (and `setSeparators` / `setUseWhitespaceAsSeparator` / `setLengthLimits`) → `$options = $options->withX($v)` — **only where `$options` is provably the sole holder**: it was created in the same scope (`new ParseOptions(...)`, a `ParseOptions::rfc*()` preset, or a `withX()` chain) and has not yet been passed anywhere, copied, or captured. A `$this->options->setX($v)` call on the object's own property becomes `$this->options = $this->options->withX($v)`. -It deliberately leaves the **semantic** changes for you to do by hand (they can't be rewritten safely): the `parse()` → `parseSingle()`/`parseMultiple()` migration (the return *shape* changes from array to object), `setMaxLocalPartLength()` etc. (rebuild a `LengthLimits`), `setOptions()` → constructor, and chained `setLogger()`. Those are covered below. +Where the receiver is shared — a parameter, a `$parser->getOptions()` result, or a variable that was already handed to `new Parse(null, $o)` — a local reassignment would silently stop affecting the other holder, so Rector **leaves the call in place** (it fails loudly on 4.0, since the setter no longer exists) and inserts a `// TODO email-parse 4.0:` comment above it explaining the manual fix: build the configured `ParseOptions` where it is created and pass it in. + +It deliberately leaves the other **semantic** changes for you to do by hand (they can't be rewritten safely): the `parse()` → `parseSingle()`/`parseMultiple()` migration (the return *shape* changes from array to object), `setMaxLocalPartLength()` etc. (rebuild a `LengthLimits`), `setOptions()` → constructor, and chained `setLogger()`. Those are covered below. ### Breaking Changes @@ -53,7 +55,7 @@ $options = (new ParseOptions()) ->withSeparators([',', ';']); ``` -The `getX()` accessors (`getBannedChars()`, `getSeparators()`, `getLengthLimits()`, `getMaxLocalPartLength()`, …) are unchanged, and the state fields are now also readable directly as `public readonly` properties (`$options->bannedChars`, `$options->separators`, etc.). +The state fields are now readable directly as `public readonly` properties (`$options->bannedChars`, `$options->separators`, `$options->lengthLimits`, …). The pass-through `getX()` accessors still work but are deprecated in favour of the properties (see *Deprecated #3*); `getMaxLocalPartLength()` / `getMaxTotalLength()` / `getMaxDomainLabelLength()` are unchanged. #### 2. `Parse::validateLocalPart()` and `validateDomainName()` are now `private` @@ -75,9 +77,21 @@ $parser->parseSingle($email); $parser = new Parse($logger, $options); ``` +#### 4. Subclass overrides of `parse()` no longer affect the typed methods + +In 3.x, `parseSingle()`, `parseMultiple()` and `parseStream()` all dispatched through the public `parse()`, so a subclass that overrode `parse()` (say, to pre-process input) sat on every code path. In 4.0 the parser core lives in a private `parseInternal()`; the typed methods call it directly and the deprecated `parse()` is a thin shim beside them. An override of `parse()` therefore only sees callers of `parse()` itself: + +``` +3.x parseSingle()/parseMultiple()/parseStream() ─► parse() ─► core +4.0 parseSingle()/parseMultiple()/parseStream() ─► parseInternal() ─► core + parse() [deprecated] ─► parseInternal() +``` + +Overriding the entry point was never a documented extension point. If you did it to rewrite input, do the rewrite before calling the parser; if you did it to post-process results, wrap the typed result instead. (`Parse` remains non-final so PSR-3 logger injection and decoration keep working.) + ### Deprecated (Still Functional) -Both keep working in the entire 4.x line and are removed in **5.0**. +Everything in this section keeps working for the whole 4.x line and is removed in **5.0**. Each call now emits a runtime `E_USER_DEPRECATED` notice via `trigger_deprecation()` (from `symfony/deprecation-contracts`), so tools such as `symfony/phpunit-bridge` will list the exact call-sites; the shipped Rector config (above) fixes most of them. #### 1. `Parse::parse()` @@ -85,16 +99,16 @@ The polymorphic `$multiple`-boolean, array-returning method is deprecated in fav ```php // Before -$rows = $parser->parse($input, true); // array of address arrays -$row = $parser->parse($input, false); // single address array +$batch = $parser->parse($input, true); // ['success' => bool, 'reason' => ?string, 'email_addresses' => [...]] +$row = $parser->parse($input, false); // single address array // After -$result = $parser->parseMultiple($input); // ParseResult (typed) -$addr = $parser->parseSingle($input); // ParsedEmailAddress (typed) +$result = $parser->parseMultiple($input); // ParseResult (typed) +$addr = $parser->parseSingle($input); // ParsedEmailAddress (typed) -// Need the legacy array shape? Call ->toArray(): -$rows = $parser->parseMultiple($input)->toArray()['email_addresses']; -$row = $parser->parseSingle($input)->toArray(); +// Need the legacy array shape? ->toArray() reproduces it exactly, envelope included: +$batch = $parser->parseMultiple($input)->toArray(); // same success/reason/email_addresses keys +$row = $parser->parseSingle($input)->toArray(); ``` #### 2. `Parse::getInstance()` diff --git a/phpstan.neon b/phpstan.neon index 092585f..d1029c8 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -8,5 +8,6 @@ parameters: - tests - rector excludePaths: + - tests/fixtures - vendor reportUnmatchedIgnoredErrors: false diff --git a/rector/rules/ParseOptionsGetterToPropertyRector.php b/rector/rules/ParseOptionsGetterToPropertyRector.php index a5d9d7d..ff14db9 100644 --- a/rector/rules/ParseOptionsGetterToPropertyRector.php +++ b/rector/rules/ParseOptionsGetterToPropertyRector.php @@ -6,6 +6,8 @@ use PhpParser\Node; use PhpParser\Node\Expr\MethodCall; +use PhpParser\Node\Expr\NullsafeMethodCall; +use PhpParser\Node\Expr\NullsafePropertyFetch; use PhpParser\Node\Expr\PropertyFetch; use PHPStan\Type\ObjectType; use Rector\Rector\AbstractRector; @@ -16,6 +18,8 @@ * Rewrites the deprecated `Email\ParseOptions` pass-through getters (removed in * 5.0) to direct reads of the corresponding `public readonly` property. * + * Nullsafe calls (`$o?->getX()`) become nullsafe property reads. + * * The getMax*Length() helpers are intentionally excluded — they read into * $lengthLimits and are not deprecated. */ @@ -48,12 +52,12 @@ public function getRuleDefinition(): RuleDefinition */ public function getNodeTypes(): array { - return [MethodCall::class]; + return [MethodCall::class, NullsafeMethodCall::class]; } public function refactor(Node $node): ?Node { - /** @var MethodCall $node */ + /** @var MethodCall|NullsafeMethodCall $node */ if ($node->isFirstClassCallable() || $node->getArgs() !== []) { return null; } @@ -67,6 +71,10 @@ public function refactor(Node $node): ?Node return null; } - return new PropertyFetch($node->var, self::GETTER_TO_PROPERTY[$method]); + $property = self::GETTER_TO_PROPERTY[$method]; + + return $node instanceof NullsafeMethodCall + ? new NullsafePropertyFetch($node->var, $property) + : new PropertyFetch($node->var, $property); } } diff --git a/rector/rules/ParseOptionsSetterToWithRector.php b/rector/rules/ParseOptionsSetterToWithRector.php index a66ad4b..3647e22 100644 --- a/rector/rules/ParseOptionsSetterToWithRector.php +++ b/rector/rules/ParseOptionsSetterToWithRector.php @@ -4,30 +4,61 @@ namespace Email\Rector; +use PhpParser\Comment; use PhpParser\Node; +use PhpParser\Node\Arg; +use PhpParser\Node\Expr; use PhpParser\Node\Expr\Assign; +use PhpParser\Node\Expr\Closure; use PhpParser\Node\Expr\MethodCall; +use PhpParser\Node\Expr\New_; +use PhpParser\Node\Expr\NullsafeMethodCall; use PhpParser\Node\Expr\PropertyFetch; +use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Expr\Variable; +use PhpParser\Node\Stmt\Class_; +use PhpParser\Node\Stmt\ClassMethod; +use PhpParser\Node\Stmt\Expression; +use PhpParser\Node\Stmt\Function_; +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitorAbstract; use PHPStan\Type\ObjectType; +use Rector\NodeTypeResolver\Node\AttributeKey; use Rector\Rector\AbstractRector; use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample; use Symplify\RuleDocGenerator\ValueObject\RuleDefinition; /** * Rewrites the removed `Email\ParseOptions` mutating setters to their immutable - * `withX()` builders, re-assigning the result back to the receiver. + * `withX()` builders — but only where that rewrite is provably equivalent. * - * The old setters mutated in place and returned void; the withX() builders return - * a new instance, so `$o->setBannedChars($x)` becomes `$o = $o->withBannedChars($x)`. - * Only applies when the receiver is directly assignable (a variable or property); - * anything more complex is left for manual migration. + * The 3.x setters mutated a shared instance, so every holder of the object saw + * the change. `$o = $o->withX(...)` only reproduces that when `$o` is the sole + * holder. The rule therefore tracks ownership per function scope, in source + * order: a variable is OWNED once it is assigned from `new ParseOptions(...)`, + * a `ParseOptions::rfc*()` preset, or a `withX()` chain, and STOPS being owned + * the moment it escapes — passed as an argument (e.g. `new Parse(null, $o)`), + * copied to another variable, captured by a closure, or reassigned from an + * unknown source such as `$parser->getOptions()`. Parameters are never owned. * - * The setMax*Length() setters are intentionally excluded — their replacement builds - * a new LengthLimits from the other two limits, which needs human context. + * owned receiver $o->setX($v) => $o = $o->withX($v) + * own property $this->opts->setX($v) => $this->opts = $this->opts->withX($v) + * anything else left as-is (it fails loudly on 4.0, the setter no longer + * exists) and annotated with a TODO comment explaining the + * manual migration, so the site stays visible in the diff. + * + * setMax*Length() is intentionally excluded: its replacement rebuilds a + * LengthLimits from the other two limits, which needs human context. */ final class ParseOptionsSetterToWithRector extends AbstractRector { + private const OPTIONS_CLASS = 'Email\ParseOptions'; + + /** Attribute set on each setter-call Expression by the ownership pre-pass. */ + private const OWNED_ATTR = 'emailParse.ownedReceiver'; + + private const TODO_MARKER = 'TODO email-parse 4.0:'; + /** @var array mutating setter => immutable builder */ private const SETTER_TO_WITH = [ 'setBannedChars' => 'withBannedChars', @@ -36,14 +67,23 @@ final class ParseOptionsSetterToWithRector extends AbstractRector 'setLengthLimits' => 'withLengthLimits', ]; + /** File path the ownership pre-pass last ran for. */ + private ?string $taggedFile = null; + public function getRuleDefinition(): RuleDefinition { return new RuleDefinition( - 'Replace a removed ParseOptions setter with its withX() builder, re-assigning the result', + 'Replace a removed ParseOptions setter with its withX() builder where the receiver is locally owned; annotate aliased receivers for manual migration', [ new CodeSample( - '$options->setBannedChars($chars);', - '$options = $options->withBannedChars($chars);', + <<<'PHP' + $options = new ParseOptions(); + $options->setBannedChars($chars); + PHP, + <<<'PHP' + $options = new ParseOptions(); + $options = $options->withBannedChars($chars); + PHP, ), ], ); @@ -54,28 +94,195 @@ public function getRuleDefinition(): RuleDefinition */ public function getNodeTypes(): array { - return [MethodCall::class]; + return [Expression::class]; } public function refactor(Node $node): ?Node { - /** @var MethodCall $node */ - $method = $this->getName($node->name); - if ($method === null || !isset(self::SETTER_TO_WITH[$method])) { + /** @var Expression $node */ + $call = $node->expr; + if (!$call instanceof MethodCall && !$call instanceof NullsafeMethodCall) { return null; } - // Only rewrite when the result can be assigned straight back to the receiver. - if (!$node->var instanceof Variable && !$node->var instanceof PropertyFetch) { + $method = $this->getName($call->name); + if ($method === null || !isset(self::SETTER_TO_WITH[$method])) { return null; } - if (!$this->isObjectType($node->var, new ObjectType('Email\ParseOptions'))) { + if (!$this->isObjectType($call->var, new ObjectType(self::OPTIONS_CLASS))) { return null; } - $with = new MethodCall($node->var, self::SETTER_TO_WITH[$method], $node->getArgs()); + $this->tagOwnershipOnce(); + + if ($node->getAttribute(self::OWNED_ATTR) === true) { + $with = new MethodCall($call->var, self::SETTER_TO_WITH[$method], $call->getArgs()); + $node->expr = new Assign($call->var, $with); + + return $node; + } + + return $this->annotateForManualMigration($node, $method); + } + + /** + * Walk the whole file once, in source order, and tag every setter-call + * statement with whether its receiver is owned at that point. + */ + private function tagOwnershipOnce(): void + { + $path = $this->file->getFilePath(); + if ($this->taggedFile === $path) { + return; + } + $this->taggedFile = $path; + + $rule = $this; + $visitor = new class ($rule) extends NodeVisitorAbstract { + /** @var list, params: array}> */ + private array $scopes = []; + + public function __construct(private readonly ParseOptionsSetterToWithRector $rule) + { + $this->scopes[] = ['owned' => [], 'params' => []]; + } + + public function enterNode(Node $node): ?int + { + if ($node instanceof ClassMethod || $node instanceof Function_ || $node instanceof Closure) { + $params = []; + foreach ($node->params as $param) { + if ($param->var instanceof Variable && \is_string($param->var->name)) { + $params[$param->var->name] = true; + } + } + $this->scopes[] = ['owned' => [], 'params' => $params]; + + return null; + } + if ($node instanceof Class_) { + $this->scopes[] = ['owned' => [], 'params' => []]; + + return null; + } + + // Escapes: once another holder can observe the instance, a local + // reassignment no longer reproduces the 3.x shared mutation. + if ($node instanceof Arg && $node->value instanceof Variable) { + $this->disown($node->value); + } + if ($node instanceof Assign && $node->expr instanceof Variable) { + $this->disown($node->expr); + } + + // Tag setter-call statements with the ownership state *before* them. + if ($node instanceof Expression) { + $call = $node->expr; + if ($call instanceof MethodCall || $call instanceof NullsafeMethodCall) { + $node->setAttribute( + ParseOptionsSetterToWithRector::ownedAttr(), + $this->isOwnedReceiver($call->var), + ); + } + } + + return null; + } + + public function leaveNode(Node $node): ?int + { + if ($node instanceof Assign && $node->var instanceof Variable && \is_string($node->var->name)) { + if ($this->rule->isOwningExpression($node->expr)) { + $this->scopes[\count($this->scopes) - 1]['owned'][$node->var->name] = true; + } else { + $this->disown($node->var); + } + } + if ($node instanceof ClassMethod || $node instanceof Function_ || $node instanceof Closure || $node instanceof Class_) { + array_pop($this->scopes); + } + + return null; + } + + private function isOwnedReceiver(Expr $receiver): bool + { + if ($receiver instanceof PropertyFetch) { + // Only the object's own property: `$this->options = $this->options->withX()`. + return $receiver->var instanceof Variable && $receiver->var->name === 'this'; + } + if (!$receiver instanceof Variable || !\is_string($receiver->name)) { + return false; + } + $scope = $this->scopes[\count($this->scopes) - 1]; + + return isset($scope['owned'][$receiver->name]) && !isset($scope['params'][$receiver->name]); + } + + private function disown(Variable $variable): void + { + if (\is_string($variable->name)) { + unset($this->scopes[\count($this->scopes) - 1]['owned'][$variable->name]); + } + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($this->file->getNewStmts()); + } + + /** + * Whether an expression produces a fresh, unshared ParseOptions instance. + * + * @internal Called from the ownership visitor. + */ + public function isOwningExpression(Expr $expr): bool + { + if ($expr instanceof New_) { + return $this->isName($expr->class, self::OPTIONS_CLASS); + } + if ($expr instanceof StaticCall) { + return $this->isName($expr->class, self::OPTIONS_CLASS); + } + if ($expr instanceof MethodCall) { + $name = $this->getName($expr->name); + + return $name !== null + && str_starts_with($name, 'with') + && $this->isObjectType($expr->var, new ObjectType(self::OPTIONS_CLASS)); + } + + return false; + } + + /** @internal Called from the ownership visitor. */ + public static function ownedAttr(): string + { + return self::OWNED_ATTR; + } + + private function annotateForManualMigration(Expression $stmt, string $setter): ?Expression + { + $comments = $stmt->getAttribute(AttributeKey::COMMENTS) ?? []; + foreach ($comments as $comment) { + if ($comment instanceof Comment && str_contains($comment->getText(), self::TODO_MARKER)) { + return null; // already annotated on a previous run + } + } + + $with = self::SETTER_TO_WITH[$setter]; + $comments[] = new Comment(sprintf( + "// %s %s() was removed and this ParseOptions is not owned here (a parameter,\n" + . "// a ->getOptions() result, or already shared). ParseOptions is immutable: configure it where\n" + . "// it is created, e.g. new Parse(\$logger, ParseOptions::rfc5322()->%s(...)). See UPGRADE.md.", + self::TODO_MARKER, + $setter, + $with, + )); + $stmt->setAttribute(AttributeKey::COMMENTS, $comments); - return new Assign($node->var, $with); + return $stmt; } } diff --git a/src/Parse.php b/src/Parse.php index 276f1f2..a4da9e8 100644 --- a/src/Parse.php +++ b/src/Parse.php @@ -306,8 +306,8 @@ private function parseInternal(string $emails, bool $multiple, string $encoding) // Per-parse accumulator. A fresh instance (never an instance property) // keeps the parser reentrant across a localPartNormalizer callback. The - // constructor takes the initial state (STATE_TRIM) and sub-state - // (STATE_START) plus the immutable input snapshot + hoisted config, which + // constructor takes the initial state (ParserState::TRIM) and sub-state + // (ParserState::START) plus the immutable input snapshot + hoisted config, which // it exposes as readonly properties — so no handler can mutate config, and // the context is fully initialized before first use. $chars/$len are also // kept as locals below for the tight loop counter. @@ -333,12 +333,12 @@ private function parseInternal(string $emails, bool $multiple, string $encoding) $this->handleStateSkipAhead($ctx, $curChar); break; - /* @noinspection PhpMissingBreakStatementInspection — STATE_TRIM falls through to STATE_ADDRESS */ + /* @noinspection PhpMissingBreakStatementInspection — ParserState::TRIM falls through to ParserState::ADDRESS */ case ParserState::TRIM: if (!$this->handleStateTrim($ctx, $curChar)) { break; } - // no break — a plain character falls through to STATE_ADDRESS + // no break — a plain character falls through to ParserState::ADDRESS case ParserState::ADDRESS: $this->handleStateAddress($ctx, $curChar, $prevChar, $i); @@ -361,8 +361,10 @@ private function parseInternal(string $emails, bool $multiple, string $encoding) break; // @codeCoverageIgnoreStart default: - // Unreachable: $ctx->state is a ParserState enum and every case - // is handled above. Kept as defensive depth against a future state. + // Defensive: the outer loop only ever assigns the seven states + // handled above; the other ParserState cases are sub-states that + // belong in $ctx->subState. Reaching here means a handler mixed + // the two up, so fail the address rather than loop silently. $ctx->originalAddress .= $curChar; $ctx->invalid = true; $ctx->invalidReason = 'Error during parsing'; @@ -373,7 +375,7 @@ private function parseInternal(string $emails, bool $multiple, string $encoding) // @codeCoverageIgnoreEnd } - // if there's a $ctx->originalAddress and the state is set to STATE_END_ADDRESS + // if there's a $ctx->originalAddress and the state is set to ParserState::END_ADDRESS if (ParserState::END_ADDRESS == $ctx->state && strlen($ctx->originalAddress) > 0) { $invalid = $this->addAddress( $emailAddresses, @@ -394,7 +396,7 @@ private function parseInternal(string $emails, bool $multiple, string $encoding) $ctx->resetAddress(ParserState::TRIM, ParserState::START); } - // Fire once, on the transition into invalid: STATE_SKIP_AHEAD does not clear + // Fire once, on the transition into invalid: ParserState::SKIP_AHEAD does not clear // the flag, so without the state guard this block would re-run every remaining // character — and interpolating the full $emails / original_address each time // (even under a NullLogger, the argument is still built) makes malformed input @@ -408,7 +410,7 @@ private function parseInternal(string $emails, bool $multiple, string $encoding) // End-of-input reached still inside a delimiter (quote, comment, domain // literal, or obs-route) — the construct was never closed. Keyed on the // parser state rather than quote_temp, since bracket/comment content is - // buffered elsewhere (a closed delimiter always returns to STATE_ADDRESS). + // buffered elsewhere (a closed delimiter always returns to ParserState::ADDRESS). if (!$ctx->invalid && in_array($ctx->state, [ParserState::QUOTE, ParserState::COMMENT, ParserState::SQUARE_BRACKET, ParserState::OBS_ROUTE], true)) { $ctx->invalid = true; [$ctx->invalidReason, $ctx->invalidReasonCode] = match ($ctx->state) { @@ -470,8 +472,8 @@ private function parseInternal(string $emails, bool $multiple, string $encoding) } /** - * STATE_SKIP_AHEAD: a bad address was seen; discard characters until the next - * separator, then let the main loop transition to STATE_END_ADDRESS. + * ParserState::SKIP_AHEAD: a bad address was seen; discard characters until the next + * separator, then let the main loop transition to ParserState::END_ADDRESS. */ private function handleStateSkipAhead(ParseContext $ctx, string $curChar): void { @@ -485,10 +487,10 @@ private function handleStateSkipAhead(ParseContext $ctx, string $curChar): void } /** - * STATE_TRIM: skip leading whitespace and detect a leading quote/comment. + * ParserState::TRIM: skip leading whitespace and detect a leading quote/comment. * * @return bool true when the character is ordinary and parsing should fall - * through to STATE_ADDRESS; false when it was consumed here + * through to ParserState::ADDRESS; false when it was consumed here */ private function handleStateTrim(ParseContext $ctx, string $curChar): bool { @@ -506,19 +508,19 @@ private function handleStateTrim(ParseContext $ctx, string $curChar): bool $ctx->originalAddress .= $curChar; $ctx->state = ParserState::COMMENT; // A leading comment opens at nest level 1 (matches the - // STATE_ADDRESS entry); without this an unbalanced nested + // ParserState::ADDRESS entry); without this an unbalanced nested // comment like "((x)" would appear closed after one ")". $ctx->commentNestLevel = 1; return false; } - // Non-whitespace, non-special char: fall through to STATE_ADDRESS processing. + // Non-whitespace, non-special char: fall through to ParserState::ADDRESS processing. return true; } /** - * STATE_ADDRESS: the main dispatch on the current character. Small structural + * ParserState::ADDRESS: the main dispatch on the current character. Small structural * branches are handled inline; the heavier ones (CFWS, '@', '.', atext and * non-atext runs) delegate to dedicated helpers below. */ @@ -599,8 +601,8 @@ private function handleStateAddress(ParseContext $ctx, string $curChar, ?string $this->handleQuote($ctx); } } elseif ('>' == $curChar) { - // Should be the end of the domain part. Accept STATE_DOMAIN - // (normal dot-atom domain) and also STATE_AFTER_DOMAIN, which a + // Should be the end of the domain part. Accept ParserState::DOMAIN + // (normal dot-atom domain) and also ParserState::AFTER_DOMAIN, which a // domain-literal (``, `]` transitions to AFTER_DOMAIN) // or trailing CFWS reaches — but only when a domain or IP is actually // present, so `` / `` still fail. @@ -724,12 +726,12 @@ private function handleStateAddress(ParseContext $ctx, string $curChar, ?string } /** - * STATE_ADDRESS whitespace (RFC 5322 §3.2.2 CFWS). Looks ahead past the WSP + * ParserState::ADDRESS whitespace (RFC 5322 §3.2.2 CFWS). Looks ahead past the WSP * run to classify the fold and decide whether it is absorbed, ends the * address, or is an error. * * @return bool true when the address is complete and the caller should stop - * processing this character (STATE_END_ADDRESS was set) + * processing this character (ParserState::END_ADDRESS was set) */ private function handleAddressWhitespace(ParseContext $ctx, string $curChar, int $i): bool { @@ -857,7 +859,7 @@ private function handleAddressWhitespace(ParseContext $ctx, string $curChar, int } /** - * STATE_ADDRESS '@' handling: reject a misplaced '@', start an obs-route, or + * ParserState::ADDRESS '@' handling: reject a misplaced '@', start an obs-route, or * flush the accumulated word(s) into the local-part and enter the domain. */ private function handleAddressAt(ParseContext $ctx): void @@ -893,7 +895,7 @@ private function handleAddressAt(ParseContext $ctx): void ) { // RFC 5322 §4.4 obs-route: first `@` seen inside `<...>` with no // preceding local-part starts the source-route prefix. Consume - // the remainder until `:` via STATE_OBS_ROUTE, then resume + // the remainder until `:` via ParserState::OBS_ROUTE, then resume // addr-spec parsing with local-part reset. $ctx->state = ParserState::OBS_ROUTE; $ctx->obsRoute = '@'; @@ -924,7 +926,7 @@ private function handleAddressAt(ParseContext $ctx): void } /** - * STATE_ADDRESS non-atext handling — UTF-8 domain/local-part characters + * ParserState::ADDRESS non-atext handling — UTF-8 domain/local-part characters * (punycode-tested for the domain) plus rejection of other stray bytes. */ private function handleAddressNonAtext(ParseContext $ctx, string $curChar): void @@ -952,7 +954,7 @@ private function handleAddressNonAtext(ParseContext $ctx, string $curChar): void } } } elseif (ParserState::START === $ctx->subState || ParserState::LOCAL_PART === $ctx->subState) { - // Handle non-atext characters in both STATE_START and STATE_LOCAL_PART consistently + // Handle non-atext characters in both ParserState::START and ParserState::LOCAL_PART consistently if ($ctx->subState === ParserState::START && $ctx->quoteTemp) { $ctx->addressTemp .= $ctx->quoteTemp; $ctx->addressTempQuoted = true; @@ -1011,7 +1013,7 @@ private function handleAddressNonAtext(ParseContext $ctx, string $curChar): void } /** - * STATE_SQUARE_BRACKET: accumulate a domain-literal IP until the closing ']'. + * ParserState::SQUARE_BRACKET: accumulate a domain-literal IP until the closing ']'. */ private function handleStateSquareBracket(ParseContext $ctx, string $curChar): void { @@ -1025,7 +1027,7 @@ private function handleStateSquareBracket(ParseContext $ctx, string $curChar): v } /** - * STATE_OBS_ROUTE (RFC 5322 §4.4): consume the `@host1,@host2:` source-route + * ParserState::OBS_ROUTE (RFC 5322 §4.4): consume the `@host1,@host2:` source-route * prefix inside angle-addr. On `:` resume addr-spec parsing; an unterminated * route (`>` or end of input before `:`) is invalid. */ @@ -1049,9 +1051,9 @@ private function handleStateObsRoute(ParseContext $ctx, string $curChar): void } /** - * STATE_QUOTE: accumulate a quoted-string, honouring backslash escapes and + * ParserState::QUOTE: accumulate a quoted-string, honouring backslash escapes and * rejecting bare C0 controls, until the real closing quote returns to - * STATE_ADDRESS. + * ParserState::ADDRESS. */ private function handleStateQuote(ParseContext $ctx, string $curChar, int $i): void { @@ -1095,7 +1097,7 @@ private function handleStateQuote(ParseContext $ctx, string $curChar, int $i): v } /** - * STATE_COMMENT (RFC 5322 §3.2.2): accumulate comment text, tracking nesting + * ParserState::COMMENT (RFC 5322 §3.2.2): accumulate comment text, tracking nesting * and quoted-pairs, and on close flag a comment that split a local-part atom. */ private function handleStateComment(ParseContext $ctx, string $curChar): void diff --git a/src/ParseOptions.php b/src/ParseOptions.php index 12c62f2..9cd3394 100644 --- a/src/ParseOptions.php +++ b/src/ParseOptions.php @@ -237,11 +237,10 @@ public static function rfc2822(): self // ===== Fluent builders ===== // - // The readonly rule properties cannot be reassigned. Each `withX()` method - // returns a new ParseOptions instance with the single field replaced and - // every other field preserved. The four non-readonly state fields - // (bannedChars, separators, useWhitespaceAsSeparator, lengthLimits) also - // have `withX()` builders for symmetry; they will become readonly in v4.0. + // Every property is readonly, so nothing can be reassigned in place. Each + // `withX()` method returns a new ParseOptions instance with the single field + // replaced and every other field preserved; this is the only way to derive + // a differently-configured instance. /** @param array $bannedChars */ public function withBannedChars(array $bannedChars): self diff --git a/tests/ParseTest.php b/tests/ParseTest.php index 31e1ce4..960cc22 100644 --- a/tests/ParseTest.php +++ b/tests/ParseTest.php @@ -1885,11 +1885,12 @@ public function testDeprecatedMethodsTriggerRuntimeDeprecations(): void { $seen = []; set_error_handler(static function (int $errno, string $message) use (&$seen): bool { - if (E_USER_DEPRECATED === $errno) { - $seen[] = $message; + if (E_USER_DEPRECATED !== $errno) { + return false; // anything else falls through to PHPUnit's handler } + $seen[] = $message; - return true; // handled — don't propagate + return true; }); try { diff --git a/tests/RectorUpgradeTest.php b/tests/RectorUpgradeTest.php new file mode 100644 index 0000000..fe5d417 --- /dev/null +++ b/tests/RectorUpgradeTest.php @@ -0,0 +1,56 @@ + 4.0 Rector config against a fixture that exercises + * every ownership branch of the setter rule (owned variable, withX() chain, + * escape by argument / copy, ->getOptions() alias, parameter, own property), + * the nullsafe getter rewrite, and getInstance(). The expected output is the + * contract users get from `rector process --config rector/upgrade-4.0.php`. + */ +final class RectorUpgradeTest extends TestCase +{ + public function testUpgradeConfigRewritesFixtureAsDocumented(): void + { + $root = \dirname(__DIR__); + $rector = $root.'/bin/rector'; + if (!is_executable($rector)) { + $this->markTestSkipped('rector/rector is not installed (dev dependency)'); + } + + $work = sys_get_temp_dir().'/email-parse-rector-'.bin2hex(random_bytes(4)); + mkdir($work); + $target = $work.'/upgrade.php'; + copy(__DIR__.'/fixtures/rector/upgrade-4.0.input.php', $target); + + try { + $cmd = sprintf( + '%s process %s --config %s --clear-cache --no-progress-bar --no-ansi 2>&1', + escapeshellarg($rector), + escapeshellarg($target), + escapeshellarg($root.'/rector/upgrade-4.0.php'), + ); + exec($cmd, $output, $exit); + $this->assertSame(0, $exit, "rector failed:\n".implode("\n", $output)); + + $this->assertStringEqualsFile( + __DIR__.'/fixtures/rector/upgrade-4.0.expected.php', + (string) file_get_contents($target), + ); + + // Idempotent: a second run must not re-annotate or re-rewrite. + exec($cmd, $output2, $exit2); + $this->assertSame(0, $exit2); + $this->assertStringEqualsFile( + __DIR__.'/fixtures/rector/upgrade-4.0.expected.php', + (string) file_get_contents($target), + ); + } finally { + @unlink($target); + @rmdir($work); + } + } +} diff --git a/tests/fixtures/rector/upgrade-4.0.expected.php b/tests/fixtures/rector/upgrade-4.0.expected.php new file mode 100644 index 0000000..e85c35c --- /dev/null +++ b/tests/fixtures/rector/upgrade-4.0.expected.php @@ -0,0 +1,61 @@ +withBannedChars(['%']); +$b = ParseOptions::rfc5322(); +$b = $b->withSeparators([';']); +$c = ParseOptions::rfc5321()->withRequireFqdn(false); +$c = $c->withUseWhitespaceAsSeparator(false); + +// escape by argument: after new Parse(null, $d) the parser shares $d +$d = new ParseOptions(); +$parser = new Parse(null, $d); +// TODO email-parse 4.0: setSeparators() was removed and this ParseOptions is not owned here (a parameter, +// a ->getOptions() result, or already shared). ParseOptions is immutable: configure it where +// it is created, e.g. new Parse($logger, ParseOptions::rfc5322()->withSeparators(...)). See UPGRADE.md. +$d->setSeparators([';']); + +// escape by copy +$e = new ParseOptions(); +$f = $e; +// TODO email-parse 4.0: setBannedChars() was removed and this ParseOptions is not owned here (a parameter, +// a ->getOptions() result, or already shared). ParseOptions is immutable: configure it where +// it is created, e.g. new Parse($logger, ParseOptions::rfc5322()->withBannedChars(...)). See UPGRADE.md. +$e->setBannedChars(['!']); + +// aliased: not created here +$g = $parser->getOptions(); +// TODO email-parse 4.0: setLengthLimits() was removed and this ParseOptions is not owned here (a parameter, +// a ->getOptions() result, or already shared). ParseOptions is immutable: configure it where +// it is created, e.g. new Parse($logger, ParseOptions::rfc5322()->withLengthLimits(...)). See UPGRADE.md. +$g->setLengthLimits(new \Email\LengthLimits(64, 254, 63)); + +function configure(ParseOptions $p): void +{ + // TODO email-parse 4.0: setBannedChars() was removed and this ParseOptions is not owned here (a parameter, + // a ->getOptions() result, or already shared). ParseOptions is immutable: configure it where + // it is created, e.g. new Parse($logger, ParseOptions::rfc5322()->withBannedChars(...)). See UPGRADE.md. + $p->setBannedChars(['%']); // parameter: never owned + $q = new ParseOptions(); + $q = $q->withSeparators([',']); // owned inside the function +} + +final class Holder +{ + private ParseOptions $opts; + public function tune(): void + { + $this->opts = $this->opts->withSeparators([';']); // own property + } +} + +// getters, incl. nullsafe; getMax* must stay +$x = $a->bannedChars; +$y = $a?->separators; +$z = $a->getMaxLocalPartLength(); +$s = new \Email\Parse(); diff --git a/tests/fixtures/rector/upgrade-4.0.input.php b/tests/fixtures/rector/upgrade-4.0.input.php new file mode 100644 index 0000000..ab6662c --- /dev/null +++ b/tests/fixtures/rector/upgrade-4.0.input.php @@ -0,0 +1,49 @@ +setBannedChars(['%']); +$b = ParseOptions::rfc5322(); +$b->setSeparators([';']); +$c = ParseOptions::rfc5321()->withRequireFqdn(false); +$c->setUseWhitespaceAsSeparator(false); + +// escape by argument: after new Parse(null, $d) the parser shares $d +$d = new ParseOptions(); +$parser = new Parse(null, $d); +$d->setSeparators([';']); + +// escape by copy +$e = new ParseOptions(); +$f = $e; +$e->setBannedChars(['!']); + +// aliased: not created here +$g = $parser->getOptions(); +$g->setLengthLimits(new \Email\LengthLimits(64, 254, 63)); + +function configure(ParseOptions $p): void +{ + $p->setBannedChars(['%']); // parameter: never owned + $q = new ParseOptions(); + $q->setSeparators([',']); // owned inside the function +} + +final class Holder +{ + private ParseOptions $opts; + public function tune(): void + { + $this->opts->setSeparators([';']); // own property + } +} + +// getters, incl. nullsafe; getMax* must stay +$x = $a->getBannedChars(); +$y = $a?->getSeparators(); +$z = $a->getMaxLocalPartLength(); +$s = Parse::getInstance(); From 85787138201f5e925fd7446dc1c3a281a1403ff6 Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Mon, 14 Sep 2026 23:37:29 -0700 Subject: [PATCH 23/23] docs: parse() decomposition shipped in 3.9.0; generic UPGRADE link The roadmap still marked the decomposition as unreleased, and the README docs line described UPGRADE.md as the v2.x -> v3.0 guide only; it now also covers v3.x -> v4.0. --- README.md | 2 +- ROADMAP.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b537a2a..f8e5b68 100644 --- a/README.md +++ b/README.md @@ -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: ------------- diff --git a/ROADMAP.md b/ROADMAP.md index 88b3e6a..6328ca4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -71,7 +71,7 @@ Continuous work, not tied to a specific release. - [x] Hot-path fix — per-character `mb_substr` (O(n²) for multi-byte encodings) replaced with a single `mb_str_split` pass and array indexing. ~10–27% faster across the suite. **Maintainability:** -- [x] **`parse()` decomposition** (delivered; unreleased). The ~772-line state-machine loop is now a ~185-line dispatch loop over per-state handler methods, backed by a typed, per-parse `ParseContext` (a fresh instance per call keeps the parser reentrant). Behavior-preserving — same logic, conditions, ordering, and output. See [ARCHITECTURE.md](ARCHITECTURE.md). Follow-ups in the backlog below. +- [x] **`parse()` decomposition** (shipped in 3.9.0). The ~772-line state-machine loop is now a ~185-line dispatch loop over per-state handler methods, backed by a typed, per-parse `ParseContext` (a fresh instance per call keeps the parser reentrant). Behavior-preserving — same logic, conditions, ordering, and output. See [ARCHITECTURE.md](ARCHITECTURE.md). Follow-ups in the backlog below. ## Strategic direction (North Star)