Skip to content

v3.1: immutable ParseOptions, typed output, error codes, new validation rules#50

Merged
mmucklo merged 1 commit intomasterfrom
feature/v3.1-immutable-typed
Apr 13, 2026
Merged

v3.1: immutable ParseOptions, typed output, error codes, new validation rules#50
mmucklo merged 1 commit intomasterfrom
feature/v3.1-immutable-typed

Conversation

@mmucklo
Copy link
Copy Markdown
Owner

@mmucklo mmucklo commented Apr 12, 2026

Summary

Completes the v3.1 roadmap. All additions are non-breaking for v3.0 callers; the one hard cutover is that the 15 ParseOptions rule properties are now readonly (direct assignment throws Error — use the new fluent withX() builders). Existing deprecated setters, factory presets, and the array-based parse() method continue to work unchanged.

What's new

Structured error codes

  • ParseErrorCode backed enum with 46 cases grouped by category (structural, character-class, dot placement, local-part content, quoted-string, domain, IP literal, length, display-name). Stable string backing values.
  • invalid_reason_code: ?ParseErrorCode on every parsed-address entry, populated alongside the existing invalid_reason string.

Typed output

  • ParsedEmailAddress — immutable value object with readonly properties for every per-address field.
  • ParseResult — immutable container for multi-address results.
  • Parse::parseSingle(string, string): ParsedEmailAddress — typed single-address entry point.
  • Parse::parseMultiple(string, string): ParseResult — typed multi-address entry point.

Immutable config

  • The 15 boolean rule properties on ParseOptions are readonly via PHP 8.1 constructor promotion.
  • 19 withX() fluent builders (15 rules + 4 state fields) that return new immutable instances with a single field replaced.
  • The 4 state fields (bannedChars, separators, useWhitespaceAsSeparator, lengthLimits) remain mutable via @deprecated setters; they will become readonly in v4.0.

New validation rules

  • validateDisplayNamePhrase — enforce RFC 5322 §3.2.5 phrase syntax (atext + WSP only) on unquoted display names. New error code: InvalidDisplayNamePhrase.
  • strictIdna — apply full IDNA2008 conformance on U-label domains (IDNA_USE_STD3_RULES | IDNA_CHECK_BIDI | IDNA_CHECK_CONTEXTJ | IDNA_NONTRANSITIONAL_TO_ASCII) per RFC 5891/5892/5893. Enabled by default in ParseOptions::rfc6531().

Migration for v3.0 users

Only one thing can break existing code: direct assignment to rule properties.

// v3.0
$options = ParseOptions::rfc5322();
$options->requireFqdn = false;  // worked

// v3.1
$options = ParseOptions::rfc5322()->withRequireFqdn(false);  // returns new instance

Factory presets, the deprecated setters (setBannedChars etc.), and parse() are all unchanged. See UPGRADE.md for full migration notes.

Test plan

  • composer ci passes (cs:check, PHPStan level 6, 14 tests / 265 assertions)
  • Existing 224 YAML test cases pass unchanged via alignReasonCode() reconciliation in the test harness
  • New tests cover: typed value objects, fluent builders, error code assertions, display-name phrase validation, IDNA strict-mode validation, readonly-property rejection

Docs updated

  • CHANGELOG.md — v3.1.0 entry with Added / Changed / Deprecated sections
  • UPGRADE.md — v3.0 → v3.1 section covering the readonly cutover and additive changes
  • ROADMAP.md — v3.1 items marked [x] with exact counts
  • README.md — typed output example in Basic Usage; withX() builders in Customizing Rules; new rule properties in the reference table

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 12, 2026

Codecov Report

❌ Patch coverage is 91.33858% with 22 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.59%. Comparing base (83669dd) to head (9578072).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
src/Parse.php 75.28% 22 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@              Coverage Diff              @@
##             master      #50       +/-   ##
=============================================
+ Coverage     78.03%   88.59%   +10.55%     
- Complexity      287      323       +36     
=============================================
  Files             3        5        +2     
  Lines           683      833      +150     
=============================================
+ Hits            533      738      +205     
+ Misses          150       95       -55     
Files with missing lines Coverage Δ
src/ParseOptions.php 100.00% <100.00%> (+19.82%) ⬆️
src/ParseResult.php 100.00% <100.00%> (ø)
src/ParsedEmailAddress.php 100.00% <100.00%> (ø)
src/Parse.php 84.80% <75.28%> (+7.39%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@mmucklo mmucklo force-pushed the feature/v3.1-immutable-typed branch 2 times, most recently from fc99fb8 to 2d2ff7b Compare April 12, 2026 20:53
…on rules

Completes the v3.1 roadmap. All additions are non-breaking for v3.0 callers;
the one hard cutover is that the 15 ParseOptions rule properties are now
readonly (direct assignment throws Error — use the new fluent withX() builders).
Existing deprecated setters, factory presets, and the array-based parse()
method continue to work unchanged.

New public API:

- ParseErrorCode backed enum (46 cases) — structured error codes covering
  every distinct failure mode the parser can emit. Backing string values
  are stable and part of the public API.

- ParsedEmailAddress — immutable value object with readonly properties for
  every per-address output field. fromArray() factory for conversion from
  the legacy array shape.

- ParseResult — immutable container for multi-address results (success,
  reason, emailAddresses).

- Parse::parseSingle(string, string): ParsedEmailAddress — typed single-
  address entry point. Recommended over parse($x, false) for new code.

- Parse::parseMultiple(string, string): ParseResult — typed multi-address
  entry point.

- ParseOptions::withX() fluent builders — 19 methods (15 rules + 4 state
  fields) that return new immutable instances with a single field replaced.

- invalid_reason_code: ?ParseErrorCode field on every parsed-address entry,
  populated at every existing invalid_reason emission site.

Immutability:

- The 15 boolean rule properties on ParseOptions are readonly via PHP 8.1
  constructor promotion. Direct assignment (e.g. $opts->requireFqdn = false)
  now throws Error. Migration: use withRequireFqdn(false) which returns a
  new instance with the change applied.

- The 4 state fields (bannedChars, separators, useWhitespaceAsSeparator,
  lengthLimits) remain mutable via deprecated setters for backward
  compatibility. They will become readonly in v4.0.

New validation rules:

- validateDisplayNamePhrase — enforce RFC 5322 §3.2.5 phrase syntax (atext
  + WSP only) on unquoted display names. Quoted-string names are always
  phrase-valid. New error code: InvalidDisplayNamePhrase.

- strictIdna — apply full IDNA2008 conformance on U-label domains:
  IDNA_USE_STD3_RULES | IDNA_CHECK_BIDI | IDNA_CHECK_CONTEXTJ
  | IDNA_NONTRANSITIONAL_TO_ASCII, plus inspection of idn_to_ascii()'s
  error bitmask (RFC 5891 §4.4, RFC 5892 Appendix A, RFC 5893).
  Enabled by default in ParseOptions::rfc6531().

Tests:

- 14 tests / 265 assertions (up from 224 / 224 in v3.0). Covers every
  new typed object, fluent builder, error code assertion, display-name
  phrase validation, and IDNA strict-mode validation.

- Test harness in tests/ParseTest.php now migrates all existing YAML
  test cases to use fluent builders (direct mutation no longer possible).

- alignReasonCode() in ParseTest lets existing YAML entries omit
  invalid_reason_code (stripped from actual output for comparison) while
  new entries opt in by specifying a ParseErrorCode string value.

Documentation:

- CHANGELOG.md: v3.1.0 entry with Added / Changed / Deprecated sections.
- UPGRADE.md: v3.0 → v3.1 section covering the readonly cutover and
  additive changes.
- ROADMAP.md: v3.1 section marked [x] with exact counts (265 assertions
  exceeds the 250+ target).
- README.md: typed output example in Basic Usage; withX() builders in the
  Customizing Rules section; new rule properties added to the reference
  table.
@mmucklo mmucklo force-pushed the feature/v3.1-immutable-typed branch from 2d2ff7b to 9578072 Compare April 13, 2026 00:09
@mmucklo mmucklo merged commit e3236cd into master Apr 13, 2026
10 checks passed
@mmucklo mmucklo deleted the feature/v3.1-immutable-typed branch April 13, 2026 00:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant