Skip to content

Fix three matcher/formatter parity gaps vs. upstream Java source - #408

Merged
twcclegg merged 5 commits into
mainfrom
improve/java-parity-cleanups
Aug 25, 2026
Merged

Fix three matcher/formatter parity gaps vs. upstream Java source#408
twcclegg merged 5 commits into
mainfrom
improve/java-parity-cleanups

Conversation

@twcclegg

Copy link
Copy Markdown
Owner

Summary

Small porting-parity pass comparing PhoneNumberMatcher/AsYouTypeFormatter/PhoneNumberUtil against the current upstream Java source (google/libphonenumber). Three genuine behavioral gaps found and fixed, each with a regression test that fails without the fix:

  • AllNumberGroupsRemainGrouped: was missing Java's step of skipping past the country calling code before locating the national-destination-code boundary, and a guard requiring the region to have a national prefix before applying the no-separator fast path.
  • ContainsMoreThanOneSlash: reduced to a bare "≥2 slashes" check; restored Java's carve-out permitting a slash that falls right after the country calling code (e.g. a number followed by a date), and fixed an off-by-one for a slash at index 0.
  • FormattingRuleHasFirstGroupOnly: existed as two separate, both-incorrect copies (in AsYouTypeFormatter and inlined in PhoneNumberMatcher), neither matching this port's "${1}" placeholder token — so metadata rules like Colombia's "(${1})" were mishandled. Consolidated into one correct shared helper on PhoneNumberUtil, mirroring Java's single shared implementation.

Each change is a minimal, targeted port of the current Java logic — no redesign or unrelated cleanup.

Test plan

  • dotnet test PhoneNumbers.Test/PhoneNumbers.Test.csproj — 412/412 passing on net8.0 and net10.0
  • New regression tests added for all three fixes, confirmed to fail on the pre-fix code

…erGroupsRemainGrouped

The C# port of PhoneNumberMatcher.AllNumberGroupsRemainGrouped (used by
STRICT_GROUPING/EXACT_GROUPING leniency) was missing two pieces of logic
present in the upstream Java since this was first ported:

- It never skipped past the country calling code before searching for the
  first formatted group, so a decoy occurrence of that group's digits
  earlier in the candidate (e.g. in leading junk before the true country
  code) could be matched instead of the real one.
- The "no separator after the NDC" fast-path compared the candidate
  directly against the national significant number whenever a digit
  followed the NDC match, without first checking (as Java does) that the
  region actually has a national destination-code prefix at all.

Both are restored to match the current Java source exactly.
PhoneNumberMatcher.ContainsMoreThanOneSlash had been reduced to a bare
"are there two or more slashes" check, dropping the number parameter and
the "permitted if the extra slashes come after the country calling code"
carve-out that the upstream Java has always had (e.g. a German number
followed by a slash-separated date, "+49/69/2013", is fine; a literal
date with no country code, "1/05/2013", is not). It also silently treated
a slash at index 0 as "no slash" (`firstSlashIndex > 0` instead of `>= 0`).

This affects STRICT_GROUPING/EXACT_GROUPING leniency in FindNumbers/IsNumberMatch,
which call this to decide whether a candidate should be rejected.

Restored to match Java's containsMoreThanOneSlashInNationalNumber exactly.
…neNumberMatcher

Java's PhoneNumberUtil.formattingRuleHasFirstGroupOnly() (rule is empty, or
just the first-group placeholder optionally wrapped in one pair of
parentheses) is shared by AsYouTypeFormatter and
PhoneNumberMatcher.isNationalPrefixPresentIfRequired. The C# port had two
independent, both-wrong copies of this check instead:

- AsYouTypeFormatter's private copy matched against the literal "$1"
  placeholder, but this port's metadata expands "$FG" to the .NET
  replacement token "${1}" (see BuildMetadataFromXml), so real rules like
  "(${1})" (e.g. Colombia's mobile format) never matched. As a result AYTF
  could wrongly keep formats that require a national prefix, or discard
  ones that don't, while filtering candidate formats as digits come in.
- PhoneNumberMatcher.IsNationalPrefixPresentIfRequired used a different,
  home-grown check (substring before "${1}", normalize digits, check
  emptiness) that only inspects what comes *before* the placeholder and
  ignores anything after it, unlike Java's whole-string check.

Added PhoneNumberUtil.FormattingRuleHasFirstGroupOnly as the single
correct port (checked against "${1}", matching this port's stored rule
format) and pointed both call sites at it, mirroring how Java shares one
implementation between the two.
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

📊 Benchmark Results

Commit: 7f74f35 · Full run · Linux ubuntu-24.04-arm

Both sides were measured on the same runner in the same job, so the numbers are
comparable. Treat sub-percent differences as noise.

PR branch

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
Neoverse-N2, 4 physical cores
.NET SDK 10.0.400
  [Host]    : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a
  .NET 10.0 : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a

Job=.NET 10.0  Runtime=.NET 10.0  

Method PhoneNumberCount Mean Error StdDev Gen0 Allocated
InputDigitPerKeystroke 1000 4.426 ms 0.0185 ms 0.0154 ms 54.6875 3.87 MB

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
Neoverse-N2, 4 physical cores
.NET SDK 10.0.400
  [Host]     : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a
  Job-AMQORM : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a

Runtime=.NET 10.0  InvocationCount=1  IterationCount=20  
LaunchCount=1  RunStrategy=ColdStart  UnrollFactor=1  
WarmupCount=1  

Method Mean Error StdDev Allocated
CreateInstance 423.3 μs 107.6 μs 123.9 μs 119.48 KB
CreateInstanceAndLoadAllRegions 7,129.0 μs 435.1 μs 501.0 μs 1620.34 KB
FirstRegionLookup 456.0 μs 136.6 μs 157.3 μs 124.54 KB

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
Neoverse-N2, 4 physical cores
.NET SDK 10.0.400
  [Host]    : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a
  .NET 10.0 : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a

Job=.NET 10.0  Runtime=.NET 10.0  

Method PhoneNumberCount Mean Error StdDev Gen0 Allocated
ExtractPossibleNumber_CleanInput 1000 21.64 μs 0.014 μs 0.013 μs - -
ExtractPossibleNumber_WithLeadingJunk 1000 38.19 μs 0.037 μs 0.033 μs 0.6714 48360 B

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
Neoverse-N2, 4 physical cores
.NET SDK 10.0.400
  [Host]    : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a
  .NET 10.0 : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a

Job=.NET 10.0  Runtime=.NET 10.0  

Method PhoneNumberCount Mean Error StdDev Gen0 Allocated
FindNumbers_Valid 100 142.1 μs 0.16 μs 0.14 μs 0.9766 70.71 KB
FindNumbers_StrictGrouping 100 318.2 μs 0.72 μs 0.68 μs 1.4648 124.84 KB

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
Neoverse-N2, 4 physical cores
.NET SDK 10.0.400
  [Host]    : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a
  .NET 10.0 : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a

Job=.NET 10.0  Runtime=.NET 10.0  

Method PhoneNumberCount Mean Error StdDev Gen0 Allocated
GetDescriptionForNumber 1000 1,453.54 μs 5.183 μs 4.848 μs 1.9531 196.59 KB
GetDisplayCountry 1000 18.53 μs 0.033 μs 0.031 μs 0.0916 7.56 KB

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
Neoverse-N2, 4 physical cores
.NET SDK 10.0.400
  [Host]    : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a
  .NET 10.0 : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a

Job=.NET 10.0  Runtime=.NET 10.0  

Method PhoneNumberCount Mean Error StdDev Gen0 Allocated
ParseValidateAndFormatPhoneNumbers 1000 2,528.8 μs 5.30 μs 4.70 μs 7.8125 577.71 KB
ParseOnly 1000 441.0 μs 0.45 μs 0.40 μs 4.8828 348.13 KB
ParseNationalFormat 1000 777.4 μs 1.22 μs 1.09 μs 5.8594 431.36 KB
ParseWithExtension 1000 1,115.0 μs 1.97 μs 1.75 μs 13.6719 957.38 KB
ValidateOnly 1000 738.6 μs 0.93 μs 0.82 μs - 41.11 KB
FormatOnly 1000 1,068.8 μs 2.60 μs 2.31 μs 1.9531 187.92 KB
PR base

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
Neoverse-N2, 4 physical cores
.NET SDK 10.0.400
  [Host]    : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a
  .NET 10.0 : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a

Job=.NET 10.0  Runtime=.NET 10.0  

Method PhoneNumberCount Mean Error StdDev Gen0 Allocated
InputDigitPerKeystroke 1000 4.340 ms 0.0135 ms 0.0106 ms 54.6875 3.87 MB

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
Neoverse-N2, 4 physical cores
.NET SDK 10.0.400
  [Host]     : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a
  Job-AMQORM : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a

Runtime=.NET 10.0  InvocationCount=1  IterationCount=20  
LaunchCount=1  RunStrategy=ColdStart  UnrollFactor=1  
WarmupCount=1  

Method Mean Error StdDev Allocated
CreateInstance 394.1 μs 115.1 μs 132.5 μs 119.48 KB
CreateInstanceAndLoadAllRegions 7,051.6 μs 384.3 μs 442.6 μs 1620.34 KB
FirstRegionLookup 466.6 μs 133.9 μs 154.2 μs 124.54 KB

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
Neoverse-N2, 4 physical cores
.NET SDK 10.0.400
  [Host]    : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a
  .NET 10.0 : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a

Job=.NET 10.0  Runtime=.NET 10.0  

Method PhoneNumberCount Mean Error StdDev Gen0 Allocated
ExtractPossibleNumber_CleanInput 1000 20.91 μs 0.015 μs 0.014 μs - -
ExtractPossibleNumber_WithLeadingJunk 1000 38.20 μs 0.024 μs 0.020 μs 0.6714 48360 B

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
Neoverse-N2, 4 physical cores
.NET SDK 10.0.400
  [Host]    : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a
  .NET 10.0 : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a

Job=.NET 10.0  Runtime=.NET 10.0  

Method PhoneNumberCount Mean Error StdDev Gen0 Allocated
FindNumbers_Valid 100 143.0 μs 0.22 μs 0.19 μs 0.9766 70.71 KB
FindNumbers_StrictGrouping 100 319.2 μs 0.74 μs 0.65 μs 1.4648 123.99 KB

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
Neoverse-N2, 4 physical cores
.NET SDK 10.0.400
  [Host]    : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a
  .NET 10.0 : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a

Job=.NET 10.0  Runtime=.NET 10.0  

Method PhoneNumberCount Mean Error StdDev Gen0 Allocated
GetDescriptionForNumber 1000 1,432.00 μs 28.615 μs 26.767 μs 1.9531 196.59 KB
GetDisplayCountry 1000 18.46 μs 0.018 μs 0.015 μs 0.0916 7.56 KB

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
Neoverse-N2, 4 physical cores
.NET SDK 10.0.400
  [Host]    : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a
  .NET 10.0 : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a

Job=.NET 10.0  Runtime=.NET 10.0  

Method PhoneNumberCount Mean Error StdDev Gen0 Allocated
ParseValidateAndFormatPhoneNumbers 1000 2,521.5 μs 5.32 μs 4.98 μs 7.8125 577.71 KB
ParseOnly 1000 437.8 μs 0.57 μs 0.54 μs 4.8828 348.13 KB
ParseNationalFormat 1000 783.5 μs 1.50 μs 1.40 μs 5.8594 431.36 KB
ParseWithExtension 1000 1,118.8 μs 2.54 μs 2.37 μs 13.6719 957.38 KB
ValidateOnly 1000 756.5 μs 14.44 μs 15.45 μs - 41.11 KB
FormatOnly 1000 1,079.9 μs 2.57 μs 2.40 μs 1.9531 187.92 KB

…OneSlash

PR #408 CI was failing with CS1503 on netstandard2.0:
  PhoneNumberMatcher.cs(647,75): error CS1503: Argument 1: cannot
  convert from 'char' to 'string'

string.Contains(char) doesn't exist on netstandard2.0 (only
Contains(string) does), but net8.0/net10.0's Roslyn analyzers flag
Contains(string) with a single-char literal as CA1847 (prefer the
char overload), and TreatWarningsAsErrors turns that into a build
failure on the modern TFMs.

Split the call with #if NETSTANDARD2_0, matching the existing
polyfill convention in this repo (see PhoneNumberUtil.netstandard.cs)
so each TFM uses the overload it actually has/prefers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 77.28%. Comparing base (a40ff86) to head (7f74f35).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #408      +/-   ##
==========================================
+ Coverage   77.26%   77.28%   +0.02%     
==========================================
  Files          39       39              
  Lines        4548     4561      +13     
  Branches     1129     1134       +5     
==========================================
+ Hits         3514     3525      +11     
- Misses        783      785       +2     
  Partials      251      251              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…ompat

PR #408 changed ContainsMoreThanOneSlash's signature from
(string candidate) to (PhoneNumber number, string candidate) to fix a
correctness bug (matching Java's testContainsMoreThanOneSlashInNationalNumber,
which needs to know whether the first slash falls after the country
calling code). That's a wanted behavioral fix, but it silently broke the
public API surface, which this repo keeps frozen until a major version rev
(see #375) and enforces via EnablePackageValidation/ApiCompat during
`dotnet pack`. CI caught it as CP0002 against the 9.0.37 baseline for all
three TFMs.

Restore the old 1-arg overload verbatim (same body that shipped in 9.0.37)
as a compatibility shim alongside the new, correct 2-arg overload. Nothing
in production code called the old signature (only the test file, which
already uses the 2-arg form), so this purely protects hypothetical
external consumers without touching the fixed logic.
@twcclegg
twcclegg merged commit decc3bd into main Aug 25, 2026
8 checks passed
@twcclegg
twcclegg deleted the improve/java-parity-cleanups branch August 25, 2026 00:18
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