File Coverage

lib/String/Fuzzy.pm
Criterion Covered Total %
statement 138 147 93.8
branch 76 128 59.3
condition 32 61 52.4
subroutine 21 21 100.0
pod 7 7 100.0
total 274 364 75.2


line stmt bran cond sub pod time code
1             ##----------------------------------------------------------------------------
2             ## String Fuzzy - ~/lib/String/Fuzzy.pm
3             ## Version v0.1.1
4             ## Copyright(c) 2025 DEGUEST Pte. Ltd.
5             ## Author: Jacques Deguest
6             ## Created 2025/03/29
7             ## Modified 2025/03/31
8             ## All rights reserved
9             ##
10             ##
11             ## This program is free software; you can redistribute it and/or modify it
12             ## under the same terms as Perl itself.
13             ##----------------------------------------------------------------------------
14             package String::Fuzzy;
15             BEGIN
16             {
17 2     2   324474 use strict;
  2         4  
  2         112  
18 2     2   12 use warnings;
  2         12  
  2         141  
19 2     2   16 use warnings::register;
  2         3  
  2         153  
20 2     2   619 use parent qw( Exporter );
  2         441  
  2         15  
21 2     2   184 use vars qw( @EXPORT_OK $VERSION );
  2         4  
  2         200  
22 2     2   1618 require overload;
23 2     2   771 use Encode qw( encode_utf8 decode_utf8 is_utf8 );
  2         28916  
  2         230  
24 2     2   17 use List::Util qw( min max );
  2         4  
  2         172  
25 2     2   36 use Scalar::Util qw( looks_like_number );
  2         4  
  2         146  
26 2     2   3588 use Text::Levenshtein::XS qw( distance );
  2         2027  
  2         172  
27 2     2   1429 use Unicode::Normalize qw( NFD );
  2         14502  
  2         293  
28 2         4498 our @EXPORT_OK = qw(
29             extract_best
30             extract_all
31             fuzzy_substring_ratio
32             partial_ratio
33             ratio
34             token_set_ratio
35             token_sort_ratio
36             );
37 2         81 our $VERSION = 'v0.1.1';
38             };
39              
40 2     2   36 use strict;
  2         4  
  2         64  
41 2     2   10 use warnings;
  2         3  
  2         6813  
42              
43             sub extract_all
44             {
45 2     2 1 3131 my( $query, $choices, %opts ) = @_;
46 2   100     13 my $scorer = $opts{scorer} || \∶
47 2 50       8 my $normalize = exists( $opts{normalize} ) ? $opts{normalize} : 1;
48              
49             # Handle undef query or choices
50 2 100 66     21 return( [] ) if( !defined( $query ) || !defined( $choices ) || ref( $choices ) ne 'ARRAY' );
      66        
51              
52             return [
53 5         15 sort { $b->[1] <=> $a->[1] } # Sort by score descending
54             map {
55 1         3 my $score = $scorer->( $query, $_, normalize => $normalize );
  4         10  
56 4         13 [$_, $score]
57             } @$choices
58             ];
59             }
60              
61             sub extract_best
62             {
63 2     2 1 8 my( $query, $choices, %opts ) = @_;
64 2   100     11 my $scorer = $opts{scorer} || \∶
65 2   50     10 my $limit = $opts{limit} || 1;
66 2 50       35 my $normalize = exists( $opts{normalize} ) ? $opts{normalize} : 1;
67              
68             # Handle undef query or choices
69 2 100 66     18 return( undef ) if( !defined( $query ) || !defined( $choices ) || ref( $choices ) ne 'ARRAY' );
      66        
70              
71             my @results = map {
72 1         5 my $score = $scorer->( $query, $choices->[$_], normalize => $normalize );
  4         8  
73 4         15 [$choices->[$_], $score, $_] # Include index
74             } 0 .. $#$choices;
75              
76 1         8 @results = sort { $b->[1] <=> $a->[1] } @results;
  5         11  
77              
78 1 50       8 return( $limit == 1 ? $results[0] : [@results[0 .. $limit - 1]] );
79             }
80              
81             sub fuzzy_substring_ratio
82             {
83 2     2 1 16 my( $needle, $haystack, %opts ) = @_;
84 2 50       8 my $normalize = exists( $opts{normalize} ) ? $opts{normalize} : 1;
85              
86             # Validate references, allow stringifiable objects
87 2 50 33     15 if( ref( $needle ) && !overload::Method( $needle, '""' ) )
    50 33        
88             {
89 0         0 die( "Needle must be a scalar or stringifiable object, not a reference" );
90             }
91             elsif( ref( $haystack ) && !overload::Method( $haystack, '""' ) )
92             {
93 0         0 die( "Haystack must be a scalar or stringifiable object, not a reference" );
94             }
95              
96 2 50       11 my $str_needle = $normalize ? _normalize( defined( $needle ) ? "$needle" : $needle ) : ( defined( $needle ) ? "$needle" : $needle );
    0          
    50          
97 2 50       14 my $str_haystack = $normalize ? _normalize( defined( $haystack ) ? "$haystack" : $haystack ) : ( defined( $haystack ) ? "$haystack" : $haystack );
    0          
    50          
98              
99 2         7 my $nlen = length( $str_needle );
100 2         5 my $hlen = length( $str_haystack );
101 2 50 33     12 return(0) if( $nlen == 0 || $hlen == 0 );
102              
103 2         4 my $max_score = 0;
104              
105 2         8 for my $window ( $nlen - 2 .. $nlen + 2 )
106             {
107 10 50 33     36 next if( $window < 3 || $window > $hlen );
108 10         23 for my $i ( 0 .. $hlen - $window )
109             {
110 335         626 my $chunk = substr( $str_haystack, $i, $window );
111 335         541 my $score = ratio( $str_needle, $chunk, normalize => 0 ); # Already normalized if needed
112 335 100       792 $max_score = $score if( $score > $max_score );
113             }
114             }
115              
116 2         19 return( $max_score );
117             }
118              
119             sub partial_ratio
120             {
121 4     4 1 992 my( $s1, $s2, %opts ) = @_;
122 4 100       22 my $normalize = exists( $opts{normalize} ) ? $opts{normalize} : 1;
123              
124             # Validate references, allow stringifiable objects
125 4 50 33     19 if( ref( $s1 ) && !overload::Method( $s1, '""' ) )
    50 33        
126             {
127 0         0 die( "Both input strings must be scalars or stringifiable objects, not references. The first string is invalid." );
128             }
129             elsif( ref( $s2 ) && !overload::Method( $s2, '""' ) )
130             {
131 0         0 die( "Both input strings must be scalars or stringifiable objects, not references. The second string is invalid." );
132             }
133              
134 4 50       16 my $str1 = $normalize ? _normalize( defined( $s1 ) ? "$s1" : $s1 ) : ( defined( $s1 ) ? "$s1" : $s1 );
    50          
    100          
135 4 50       14 my $str2 = $normalize ? _normalize( defined( $s2 ) ? "$s2" : $s2 ) : ( defined( $s2 ) ? "$s2" : $s2 );
    50          
    100          
136              
137 4 50       13 ( $str1, $str2 ) = ( $str2, $str1 ) if( length( $str1 ) > length( $str2 ) );
138 4 50       9 return(0) if( length( $str1 ) == 0 );
139              
140 4         4 my $max_score = 0;
141 4         5 my $s1_len = length( $str1 );
142 4         5 my $s2_len = length( $str2 );
143              
144             # Check for full containment first
145 4 100       10 if( index( $str2, $str1 ) != -1 )
146             {
147 1         3 return(100);
148             }
149              
150             # Slide window of s1's length over s2, ensuring typo tolerance
151 3         8 for my $i ( 0 .. $s2_len - $s1_len )
152             {
153 22         28 my $substr = substr( $str2, $i, $s1_len );
154 22         26 my $score = ratio( $str1, $substr, normalize => $normalize ); # Use caller's normalize setting
155 22         37 $max_score = max( $max_score, $score ); # Explicitly use max()
156             }
157              
158 3         11 return( $max_score );
159             }
160              
161             sub ratio
162             {
163 384     384 1 192117 my( $s1, $s2, %opts ) = @_;
164 384 100       728 my $normalize = exists( $opts{normalize} ) ? $opts{normalize} : 1;
165              
166             # Validate references, allow stringifiable objects
167 384 100 100     1309 if( ref( $s1 ) && !overload::Method( $s1, '""' ) )
    50 33        
168             {
169 1         13 die( "Both input strings must be scalars or stringifiable objects, not references. The first string is invalid." );
170             }
171             elsif( ref( $s2 ) && !overload::Method( $s2, '""' ) )
172             {
173 0         0 die( "Both input strings must be scalars or stringifiable objects, not references. The second string is invalid." );
174             }
175              
176 383 100       887 my $str1 = $normalize ? _normalize( defined( $s1 ) ? "$s1" : $s1 ) : ( defined( $s1 ) ? "$s1" : $s1 );
    50          
    100          
177 383 100       784 my $str2 = $normalize ? _normalize( defined( $s2 ) ? "$s2" : $s2 ) : ( defined( $s2 ) ? "$s2" : $s2 );
    50          
    100          
178              
179 383 100       776 return(100) if( $str1 eq $str2 );
180 374 100 50     1934 return(0) if( !length( $str1 // '' ) || !length( $str2 // '' ) );
      50        
      100        
181              
182 371         687 my $distance = distance( $str1, $str2 );
183 371 100       4485 my $length = $normalize
    50          
    100          
184             ? max( length( $str1 ), length( $str2 ) )
185             : max( length( is_utf8( $str1 ) ? encode_utf8( $str1 ) : $str1 ), length( is_utf8( $str2 ) ? encode_utf8( $str2 ) : $str2 ) );
186              
187 371         993 return( ( 1 - $distance / $length ) * 100 ); # Keep as float
188             }
189              
190             sub token_set_ratio
191             {
192 1     1 1 5 my( $s1, $s2, %opts ) = @_;
193 1 50       5 my $normalize = exists( $opts{normalize} ) ? $opts{normalize} : 1;
194              
195             # Validate references, allow stringifiable objects
196 1 50 33     9 if( ref( $s1 ) && !overload::Method( $s1, '""' ) )
    50 33        
197             {
198 0         0 die( "Both input strings must be scalars or stringifiable objects, not references. The first string is invalid." );
199             }
200             elsif( ref( $s2 ) && !overload::Method( $s2, '""' ) )
201             {
202 0         0 die( "Both input strings must be scalars or stringifiable objects, not references. The second string is invalid." );
203             }
204              
205 1 50       7 my $str1 = $normalize ? _normalize( defined( $s1 ) ? "$s1" : $s1 ) : ( defined( $s1 ) ? "$s1" : $s1 );
    0          
    50          
206 1 50       6 my $str2 = $normalize ? _normalize( defined( $s2 ) ? "$s2" : $s2 ) : ( defined( $s2 ) ? "$s2" : $s2 );
    0          
    50          
207              
208 1         4 my @tokens1 = split( /\s+/, $str1 );
209 1         4 my @tokens2 = split( /\s+/, $str2 );
210              
211 1         2 my %count;
212 1         8 $count{ $_ }++ for( @tokens1, @tokens2 );
213 1         4 my @intersection = grep { $count{$_} > 1 } keys( %count );
  2         9  
214 1 50       2 my @left = grep { !$count{ $_ } || $count{ $_ } == 1 } @tokens1;
  2         30  
215 1 50       2 my @right = grep { !$count{ $_ } || $count{ $_ } == 1 } @tokens2;
  2         10  
216              
217 1         7 my $sorted_common = join( ' ', sort( @intersection ) );
218 1         3 my $combined_left = join( ' ', sort( @intersection, @left ) );
219 1         3 my $combined_right = join( ' ', sort( @intersection, @right ) );
220              
221 1         5 return max(
222             ratio( $sorted_common, $combined_left, normalize => 0 ),
223             ratio( $sorted_common, $combined_right, normalize => 0 ),
224             ratio( $combined_left, $combined_right, normalize => 0 )
225             );
226             }
227              
228             sub token_sort_ratio
229             {
230 1     1 1 5 my( $s1, $s2, %opts ) = @_;
231 1 50       5 my $normalize = exists( $opts{normalize} ) ? $opts{normalize} : 1;
232              
233             # Validate references, allow stringifiable objects
234 1 50 33     10 if( ref( $s1 ) && !overload::Method( $s1, '""' ) )
    50 33        
235             {
236 0         0 die( "Both input strings must be scalars or stringifiable objects, not references. The first string is invalid." );
237             }
238             elsif( ref( $s2 ) && !overload::Method( $s2, '""' ) )
239             {
240 0         0 die( "Both input strings must be scalars or stringifiable objects, not references. The second string is invalid." );
241             }
242              
243 1 50       7 my $str1 = $normalize ? _normalize( defined( $s1 ) ? "$s1" : $s1 ) : ( defined( $s1 ) ? "$s1" : $s1 );
    0          
    50          
244 1 50       28 my $str2 = $normalize ? _normalize( defined( $s2 ) ? "$s2" : $s2 ) : ( defined( $s2 ) ? "$s2" : $s2 );
    0          
    50          
245              
246 1         7 my $sorted1 = join( ' ', sort( split( /\s+/, $str1 ) ) );
247 1         5 my $sorted2 = join( ' ', sort( split( /\s+/, $str2 ) ) );
248 1         4 return( ratio( $sorted1, $sorted2, normalize => 0 ) );
249             }
250              
251             sub _normalize
252             {
253 86     86   119 my( $str ) = @_;
254 86 100       135 return( '' ) unless( defined( $str ) );
255 84         102 $str = lc( $str );
256 84         274 $str = NFD( $str );
257 84         184 $str =~ s/\pM+//g; # Remove diacritics
258 84         122 $str =~ s/[^\p{L}\p{Nd}\s]//g; # Remove punctuation/symbols
259 84         175 $str =~ s/\s+/ /g; # Normalize whitespace
260 84         342 $str =~ s/^\s+|\s+$//g; # Trim
261 84         125 return( $str );
262             }
263              
264             1;
265             # NOTE: POD
266             __END__