File Coverage

blib/lib/String/License.pm
Criterion Covered Total %
statement 84 84 100.0
branch 21 22 95.4
condition 6 15 40.0
subroutine 19 19 100.0
pod n/a
total 130 140 92.8


line stmt bran cond sub pod time code
1 10     10   11182195 use v5.20;
  10         50  
2 10     10   64 use utf8;
  10         24  
  10         112  
3 10     10   338 use warnings;
  10         20  
  10         750  
4 10     10   61 use feature qw(signatures);
  10         22  
  10         1594  
5 10     10   68 no warnings qw(experimental::signatures);
  10         18  
  10         556  
6              
7 10     10   5269 use Feature::Compat::Class 0.07;
  10         6439  
  10         89  
8              
9             =head1 NAME
10              
11             String::License - detect source code license statements in a text string
12              
13             =head1 VERSION
14              
15             Version v0.0.11
16              
17             =head1 SYNOPSIS
18              
19             use String::License;
20             use String::License::Naming::Custom;
21              
22             my $string = 'Licensed under same terms as Perl itself';
23              
24             my $expressed = String::License->new( string => $string );
25             my $expression = $expressed->as_text; # => "Perl"
26              
27             my $desc = String::License::Naming::Custom->new;
28             my $described = String::License->new( string => $string, naming => $desc );
29             my $description = $described->as_text; # => "The Perl 5 License"
30              
31             =head1 DESCRIPTION
32              
33             L identifies license statements in a string
34             and serializes them in a normalized format.
35              
36             =cut
37              
38             package String::License v0.0.11;
39              
40 10     10   1207 use Carp qw(croak);
  10         28  
  10         657  
41 10     10   5373 use Log::Any ();
  10         109920  
  10         447  
42 10     10   83 use Scalar::Util qw(blessed);
  10         24  
  10         701  
43 10     10   67 use List::Util qw(uniq);
  10         19  
  10         806  
44 10     10   5375 use Array::IntSpan;
  10         39905  
  10         515  
45 10     10   4885 use Regexp::Pattern::License 3.4.0;
  10         2156031  
  10         1651  
46 10     10   5885 use Regexp::Pattern 0.2.12;
  10         18498  
  10         77  
47 10     10   5702 use String::License::Naming::Custom;
  10         62  
  10         532  
48 10     10   4757 use String::License::Naming::SPDX;
  10         62  
  10         459  
49              
50 10     10   70 use namespace::clean;
  10         16  
  10         47  
51              
52             class Tag {
53             field $name : param;
54             field $desc : param;
55             field $begin : param;
56             field $end : param;
57              
58             method data () { return lc __CLASS__, $name, $desc, $begin, $end }
59             }
60              
61             class Exception : isa(Tag) {;}
62              
63             class Flaw : isa(Tag) {;}
64              
65             class Licensing {
66             field $name : param;
67             field $desc : param;
68              
69             method data () { return lc __CLASS__, $name, $desc }
70             }
71              
72             class Fulltext : isa(Tag) {;}
73              
74             class Grant : isa(Tag) {;}
75              
76             class String::License;
77              
78             # try enable RE2 engine
79             eval { require re::engine::RE2 };
80             my @OPT_RE2 = $@ ? () : ( engine => 'RE2' );
81              
82             field $log;
83              
84             =head1 CONSTRUCTOR
85              
86             =over
87              
88             =item new
89              
90             my $licensed = String::License->new( string => 'Licensed under GPLv2' );
91              
92             Accepts named arguments,
93             and constructs and returns a String::License object.
94              
95             The following options are recognized:
96              
97             =over
98              
99             =item string => STRING
100              
101             The scalar string to parse for licensing information.
102              
103             =cut
104              
105             field $string : param = undef;
106              
107             =item naming => OBJ
108              
109             A L object,
110             used to define license naming conventions.
111              
112             By default uses L.
113              
114             Since instantiation of naming schemes is expensive,
115             there can be a significant speed boost
116             in passing a pre-initialized naming object
117             when processing multiple strings.
118              
119             =cut
120              
121             field $naming : param = undef;
122              
123             field $license = '';
124             field $expr = '';
125              
126             field $coverage;
127             field @loose_licensing;
128             field %fulltext;
129             field %grant;
130              
131             =back
132              
133             =back
134              
135             =cut
136              
137             ADJUST {
138             $log = Log::Any->get_logger;
139              
140             if ( defined $naming ) {
141             croak $log->fatal(
142             'parameter "naming" must be a String::License::Naming object')
143             unless defined blessed($naming)
144             and $naming->isa('String::License::Naming');
145             }
146             else {
147             $naming = String::License::Naming::SPDX->new;
148             }
149              
150             $coverage = Array::IntSpan->new();
151             }
152              
153             =head1 METHODS
154              
155             =over
156              
157             =cut
158              
159             method note ( $name, $begin = undef, $end = undef )
160             {
161             my $obj;
162              
163             if ( ref($name) ) {
164             $obj = $name;
165             ( undef, $name, undef, $begin, $end ) = $obj->data;
166             }
167              
168             $log->tracef(
169             'noted %s: %d-%d "%s"',
170             $name, $begin, $end,
171             substr( $string, $begin, $end - $begin )
172             );
173              
174             return $obj
175             if $obj;
176              
177             return [ $begin, $end ];
178             }
179              
180             method tag ($obj)
181             {
182             my ( $type, $name, $desc, $begin, $end ) = $obj->data;
183              
184             if ( $type eq 'licensing' ) {
185             push @loose_licensing, [ $type, $name, $desc ];
186             }
187             else {
188             $coverage->set_range( $begin, $end, [ $type, $name, $desc ] );
189             $log->tracef(
190             'tagged %s: %s: %d-%d',
191             $type, $desc, $begin, $end
192             );
193             }
194              
195             return $obj;
196             }
197              
198             method contains_tag ( $begin, $end )
199             {
200             return defined( $coverage->get_range( $begin, $end )->get_element(0) );
201             }
202              
203             method get_tags ()
204             {
205             my ( @thing, %set );
206              
207             @thing = $coverage->get_range_list;
208             $set{grant}{ $_->[1] } = $_ for @loose_licensing;
209             for my $i ( 0 .. $#thing ) {
210             my ( $begin, $end, $thing, $type, $key );
211              
212             ( $begin, $end, $thing ) = $coverage->get_element($i);
213             $type = $thing->[0];
214              
215             # TODO: drop fallback when all flaws have shortname
216             $key = $thing->[1] || $thing->[2];
217              
218             next unless $type =~ /^[a-z]/;
219              
220             $set{$type}{$key} = $thing;
221             }
222              
223             return (
224             [ values %{ $set{fulltexts} }, values %{ $set{grant} } ],
225             [ values %{ $set{exception} } ],
226             [ values %{ $set{flaw} } ],
227             );
228             }
229              
230             method string ()
231             {
232             return $string;
233             }
234              
235             my $any = '[A-Za-z_][A-Za-z0-9_]*';
236             my $str = '[A-Za-z][A-Za-z0-9_]*';
237             my $re_prop_attrs = qr/
238             \A(?'prop'$str)\.alt(?:
239             \.org\.(?'org'$str)|
240             \.version\.(?'version'$str)|
241             \.since\.date_(?'since_date'\d{8})|
242             \.until\.date_(?'until_date'\d{8})|
243             \.synth\.$any|
244             (?'other'\.$any)
245             )*\z/x;
246              
247             method best_value ( $hashref, @props )
248             {
249             my $value;
250              
251             PROPERTY:
252             for my $prop (@props) {
253             for my $org ( $naming->list_schemes ) {
254             for ( keys %$hashref ) {
255             /$re_prop_attrs/;
256             next
257             if not defined $+{prop}
258             or $+{prop} ne $prop
259             or not defined $+{org}
260             or $+{org} ne $org
261             or defined $+{version}
262             or defined $+{other}
263             or defined $+{until_date};
264              
265             $value = $hashref->{$_};
266             last PROPERTY;
267             }
268             }
269             $value ||= $hashref->{$prop};
270             }
271              
272             return $value;
273             }
274              
275             method name_and_desc ($id)
276             {
277             my ( $ref, %result );
278              
279             $ref = $Regexp::Pattern::License::RE{$id};
280             $result{name} = $self->best_value( $ref, 'name' ) || $id;
281             $result{desc}
282             = $self->best_value( $ref, 'caption' ) || $ref->{name} || $id;
283              
284             return \%result;
285             }
286              
287             my $license_contains_license_re
288             = qr/^license:contains:license:([a-z][a-z0-9_]*)/;
289             my $type_re
290             = qr/^type:([a-z][a-z0-9_]*)(?::([a-z][a-z0-9_]*))?(?::([a-z][a-z0-9_]*))?/;
291              
292             our %RE;
293             my (%L, @EXCEPTIONS, @LICENSES, @NAMES, @USAGE, @SINGLEVERSION, @VERSIONED,
294             @UNVERSIONED, @COMBO, @GROUP
295             );
296              
297             method init_licensepatterns ()
298             {
299             # reuse if already resolved
300             return %L if exists $L{re_trait};
301              
302             Regexp::Pattern->import(
303             're',
304             'License::*' => (
305             @OPT_RE2,
306             subject => 'trait',
307             -prefix => 'EXCEPTION_',
308             -has_tag_matching => '^type:trait:exception(?:\z|:)',
309             -lacks_tag_matching => '^type:trait:exception:prefix(?:\z|:)',
310             ),
311             'License::*' => (
312             @OPT_RE2,
313             capture => 'named',
314             subject => 'trait',
315             -prefix => 'TRAIT_',
316             -has_tag_matching => '^type:trait(?:\z|:)',
317             -lacks_tag_matching => '^type:trait:exception(?!:prefix)(?:\z|:)',
318             ),
319             'License::version' => (
320             @OPT_RE2,
321             capture => 'named',
322             subject => 'trait',
323             anchorleft => 1,
324             -prefix => 'ANCHORLEFT_NAMED_',
325             ),
326             'License::version_later' => (
327             @OPT_RE2,
328             capture => 'named',
329             subject => 'trait',
330             anchorleft => 1,
331             -prefix => 'ANCHORLEFT_NAMED_',
332             ),
333             'License::any_of' => (
334             subject => 'trait',
335             -prefix => 'LOCAL_TRAIT_',
336             ),
337             'License::by_fsf' => (
338             subject => 'trait',
339             -prefix => 'LOCAL_TRAIT_',
340             ),
341             'License::fsf_unlimited' => (
342             subject => 'trait',
343             -prefix => 'LOCAL_TRAIT_',
344             ),
345             'License::fsf_unlimited_retention' => (
346             subject => 'trait',
347             -prefix => 'LOCAL_TRAIT_',
348             ),
349             'License::licensed_under' => (
350             subject => 'trait',
351             -prefix => 'LOCAL_TRAIT_',
352             ),
353             'License::or_at_option' => (
354             subject => 'trait',
355             -prefix => 'LOCAL_TRAIT_',
356             ),
357             'License::version' => (
358             capture => 'named',
359             subject => 'trait',
360             -prefix => 'LOCAL_TRAIT_KEEP_',
361             ),
362             'License::apache' => (
363             subject => 'name',
364             -prefix => 'LOCAL_NAME_',
365             ),
366             'License::gpl' => (
367             subject => 'name',
368             -prefix => 'LOCAL_NAME_',
369             ),
370             'License::lgpl' => (
371             subject => 'name',
372             -prefix => 'LOCAL_NAME_',
373             ),
374             'License::mit' => (
375             subject => 'name',
376             -prefix => 'LOCAL_NAME_',
377             ),
378             'License::*' => (
379             @OPT_RE2,
380             subject => 'name',
381             -prefix => 'NAME_',
382             anchorleft => 1,
383             -lacks_tag_matching => '^type:trait(?:\z|:)',
384             ),
385             'License::*' => (
386             @OPT_RE2,
387             subject => 'grant',
388             -prefix => 'GRANT_',
389             -lacks_tag_matching => '^type:trait(?:\z|:)',
390             ),
391             'License::*' => (
392             @OPT_RE2,
393             subject => 'license',
394             -prefix => 'LICENSE_',
395             -lacks_tag_matching => '^type:trait(?:\z|:)',
396             ),
397             );
398              
399             my @license_containers;
400             for my $key ( grep {/^[a-z]/} keys %Regexp::Pattern::License::RE ) {
401             my $val = $Regexp::Pattern::License::RE{$key};
402              
403             ( $L{name}{$key}, $L{caption}{$key} )
404             = @{ $self->name_and_desc($key) }{ 'name', 'desc' };
405             for ( @{ $val->{tags} } ) {
406             if (/$license_contains_license_re/) {
407             $L{contained_licenses}{$key}{$1} = undef;
408             push @license_containers, $key;
409             }
410             /$type_re/ or next;
411             $L{type}{$1}{$key} = 1;
412             if ( $2 and $1 eq 'singleversion' ) {
413             $L{series}{$key} = $2;
414             }
415             if ( $2 and $1 eq 'usage' ) {
416             $L{usage}{$key} = $2;
417             }
418              
419             # TODO: simplify, and require Regexp::Pattern::License v3.9.0
420             if ( $3 and $1 eq 'trait' ) {
421             if ( substr( $key, 0, 14 ) eq 'except_prefix_' ) {
422             $L{TRAITS_exception_prefix}{$key} = undef;
423             }
424             else {
425             $L{"TRAITS_$2_$3"}{$key} = undef;
426             }
427             }
428             }
429             }
430             for my $container (@license_containers) {
431             for my $contained ( keys %{ $L{contained_licenses}{$container} } ) {
432             if ( exists $L{contained_licenses}->{$contained} ) {
433             $L{contained_licenses}{$container}{$_} = undef
434             for keys %{ $L{contained_licenses}{$contained} };
435             }
436             }
437             }
438              
439             # list by amount of contained licenses (fewest first),
440             # then key length (longest first), then alphabetically
441             @LICENSES = sort {
442             keys %{ $L{contained_licenses}{$b} } <=>
443             keys %{ $L{contained_licenses}{$a} }
444             || length($b) <=> length($a)
445             || $a cmp $b
446             }
447             map {/^LICENSE_(.*)/} keys %RE;
448              
449             # list by key length (longest first), then alphabetically
450             @NAMES = sort { length($b) <=> length($a) || $a cmp $b }
451             map {/^NAME_(.*)/} keys %RE;
452              
453             # list alphabetically
454             @EXCEPTIONS = sort map {/^EXCEPTION_(.*)/} keys %RE;
455              
456             @USAGE = grep { exists $L{type}{usage}{$_} } @LICENSES;
457             @SINGLEVERSION
458             = grep { exists $L{type}{singleversion}{$_} } @LICENSES;
459             @VERSIONED = grep { exists $L{type}{versioned}{$_} } @LICENSES;
460             @UNVERSIONED
461             = grep { exists $L{type}{unversioned}{$_} } @LICENSES;
462             @COMBO = grep { exists $L{type}{combo}{$_} } @LICENSES;
463             @GROUP = grep { exists $L{type}{group}{$_} } @LICENSES;
464              
465             # FIXME: drop when perl doesn't mysteriously freak out over it
466             $L{re_trait}{any_of} = '';
467              
468             #<<< do not let perltidy touch this (keep long regex on one line)
469             $L{multi_1} = qr/$RE{LOCAL_TRAIT_licensed_under}$RE{LOCAL_TRAIT_any_of}(?:[^.]|\.\S)*$RE{LOCAL_NAME_lgpl}$RE{LOCAL_TRAIT_KEEP_version}?/i;
470             $L{multi_2} = qr/$RE{LOCAL_TRAIT_licensed_under}$RE{LOCAL_TRAIT_any_of}(?:[^.]|\.\S)*$RE{LOCAL_NAME_gpl}$RE{LOCAL_TRAIT_KEEP_version}?/i;
471             $L{lgpl_5} = qr/$RE{LOCAL_TRAIT_licensed_under}$RE{LOCAL_NAME_lgpl}(?:$RE{LOCAL_TRAIT_by_fsf})?$RE{LOCAL_TRAIT_KEEP_version}(?:,? ?$RE{LOCAL_TRAIT_or_at_option} $RE{LOCAL_TRAIT_KEEP_version})?/i;
472             $L{gpl_7} = qr/either $RE{LOCAL_NAME_gpl}$RE{LOCAL_TRAIT_KEEP_version}?(?: \((?:the )?"?GPL"?\))?, or $RE{LOCAL_NAME_lgpl}$RE{LOCAL_TRAIT_KEEP_version}?/i;
473             $L{apache_1} = qr/$RE{LOCAL_NAME_apache}$RE{LOCAL_TRAIT_KEEP_version}?(?:(?: or)? [^ ,]*?apache[^ ,]*| \([^(),]+\))*,? or $RE{LOCAL_NAME_gpl}$RE{LOCAL_TRAIT_KEEP_version}?/i;
474             $L{apache_2} = qr/$RE{LOCAL_NAME_apache}$RE{LOCAL_TRAIT_KEEP_version}?(?:(?: or)? [^ ,]*?apache[^ ,]*| \([^(),]\))*,? or(?: the)? bsd(?P[ -](\d)-clause)?\b/i;
475             $L{apache_4} = qr/$RE{LOCAL_NAME_apache}$RE{LOCAL_TRAIT_KEEP_version}?(?:(?: or)? [^ ,]*?apache[^ ,]*| \([^(),]\))*,? or $RE{LOCAL_NAME_mit}\b/i;
476             $L{fsful} = qr/This (\w+)(?: (?:file|script))? is free software; $RE{LOCAL_TRAIT_fsf_unlimited}/i;
477             $L{fsfullr} = qr/This (\w+)(?: (?:file|script))? is free software; $RE{LOCAL_TRAIT_fsf_unlimited_retention}/i;
478             #>>>
479             }
480              
481             # license objects where atomic scan must always be applied
482             my %L_grant_stepwise_incomplete = (
483              
484             # usage
485              
486             # singleversion
487             apache_2 => 1,
488              
489             # versioned
490             gpl => 1,
491             lgpl => 1,
492              
493             # other
494             mit_new => 1, # misdetects ambiguous "MIT X11" grant
495             public_domain => 1,
496             );
497              
498             # license objects where stepwise scan cannot be skipped
499             my %L_grant_atomic_incomplete = (
500             afl_1_1 => 1,
501             afl_1_2 => 1,
502             afl_2 => 1,
503             afl_2_1 => 1,
504             afl_3 => 1,
505             apache_1_1 => 1,
506             artistic_1 => 1,
507             artistic_2 => 1,
508             bsl_1 => 1,
509             cc_by_2_5 => 1,
510             cc_by_sa => 1,
511             cpl_1 => 1,
512             mpl => 1,
513             mpl_1 => 1,
514             mpl_1_1 => 1,
515             mpl_2 => 1,
516             openssl => 1,
517             postgresql => 1,
518             zpl_2_1 => 1,
519             );
520              
521             # scan for grants first stepwise and if not found then also atomic
522             # flip either of these flags to test stepwise/atomic pattern coverage
523             my $skip_stepwise = 0;
524             my $force_atomic = 0;
525              
526             my $id2patterns_re = qr/(.*)(?:_(\d+(?:\.\d+)*)(_or_later)?)?/;
527              
528             method resolve ()
529             {
530             $self->init_licensepatterns;
531              
532             my @spdx_gplver;
533              
534             my @agpl = qw(agpl agpl_1 agpl_2 agpl_3);
535             my @gpl = qw(gpl gpl_1 gpl_2 gpl_3);
536             my @lgpl = qw(lgpl lgpl_2 lgpl_2_1 lgpl_3);
537              
538             my %match;
539              
540 3     3   6 my $patterns2id = sub ( $stem, $version = undef ) {
  3         7  
  3         10  
  3         6  
541 3 50       12 return $stem
542             unless ($version);
543 3         10 $version =~ tr/./_/;
544 3         24 $version =~ s/_0$//g;
545 3         23 return "${stem}_$version";
546             };
547 222     222   549 my $id2patterns = sub ($id) {
  222         642  
  222         370  
548 222         3503 return $id =~ /$id2patterns_re/;
549             };
550 332         846 my $gen_license = sub (
551 332         712 $id, $v = undef, $later = undef, $id2 = undef, $v2 = undef,
  332         650  
  332         609  
  332         671  
552             $later2 = undef
553             )
554 332     332   763 {
  332         568  
  332         544  
555 332         845 my ( @spdx, $name, $desc, $name2, $desc2, $legacy, $expr );
556              
557 332   33     2120 $name = $L{name}{$id} || $id;
558 332   33     1506 $desc = $L{caption}{$id} || $id;
559 332 100       1020 if ($v) {
560 5 100       33 push @spdx, $later ? "$name-$v+" : "$name-$v";
561 5 100       21 $v .= ' or later' if ($later);
562             }
563             else {
564 327         1157 push @spdx, $name;
565             }
566 332 100       980 if ($id2) {
567 5   33     32 $name2 = $L{name}{$id2} || $id2;
568 5   33     23 $desc2 = $L{caption}{$id2} || $id2;
569 5 100       19 if ($v2) {
570 4 100       19 push @spdx, $later2 ? "$name2-$v2+" : "$name2-$v2";
571 4 100       16 $v2 .= ' or later' if ($later2);
572             }
573             else {
574 1         3 push @spdx, $name2;
575             }
576             }
577 332 100       2040 $legacy = join(
    100          
    100          
578             ' ',
579             $desc,
580             $v ? "(v$v)" : (),
581             $desc2 ? "or $desc2" : (),
582             $v2 ? "(v$v2)" : (),
583             );
584 332         1290 $expr = join( ' or ', sort @spdx );
585             $self->tag(
586             Licensing->new(
587             name => $expr,
588 332   66     9709 desc => $L{caption}{$legacy} || $legacy,
589             )
590             );
591             };
592              
593             # fulltext
594             $log->trace('scan for license fulltext');
595             my %pos_license;
596             for my $id (@LICENSES) {
597             next unless ( $RE{"LICENSE_$id"} );
598             while ( $string =~ /$RE{"LICENSE_$id"}/g ) {
599             $pos_license{ $-[0] }{obj}{$id} = $self->note(
600             Fulltext->new(
601             %{ $self->name_and_desc($id) },
602             begin => $-[0], end => $+[0]
603             )
604             );
605             $pos_license{ $-[0] }{end}{$id} = $+[0];
606             }
607             }
608             for my $trait ( keys %{ $L{TRAITS_exception_prefix} } ) {
609             next unless ( $string =~ /$RE{"TRAIT_$trait"}/ );
610             while ( $string =~ /$RE{"TRAIT_$trait"}/g ) {
611             next if $self->contains_tag( $-[0], $+[0] );
612             $self->note( $trait, $-[0], $+[0] );
613             }
614             }
615             for my $pos ( sort { $a <=> $b } keys %pos_license ) {
616              
617             # pick longest or most specific among matched license fulltexts
618             my ($longest)
619             = sort { $b <=> $a } values %{ $pos_license{$pos}{end} };
620             my @licenses = grep {
621             exists $pos_license{$pos}{end}{$_}
622             and $pos_license{$pos}{end}{$_} eq $longest
623             } @LICENSES;
624             my $id = $licenses[0];
625             next
626             if not $id
627             or $self->contains_tag( $pos, $pos_license{$pos}{end}{$id} );
628             $fulltext{$id} = $self->tag( $pos_license{$pos}{obj}{$id} );
629             }
630              
631             # grant, stepwise
632             my @prefixes;
633             $log->trace('scan stepwise for license grant');
634             for my $trait ( keys %{ $L{TRAITS_grant_prefix} } ) {
635             while ( $string =~ /$RE{"TRAIT_$trait"}/g ) {
636             next if $self->contains_tag( $-[0], $+[0] );
637             push @prefixes, $self->note( $trait, $-[0], $+[0] );
638             }
639             }
640             LICENSED_UNDER:
641             for my $licensed_under ( sort { $a->[1] <=> $b->[1] } @prefixes ) {
642             my $pos = $licensed_under->[1];
643              
644             # possible grant names
645             my @grant_types = (
646             @COMBO,
647             @UNVERSIONED,
648             @VERSIONED,
649             @SINGLEVERSION,
650             @USAGE,
651             );
652              
653             # optional grant version
654             my ( $version, $suffix );
655              
656             # scan for prepended version
657             substr( $string, $pos ) =~ $RE{ANCHORLEFT_NAMED_version};
658             if ( defined $+{version_number} ) {
659             $self->note( 'version', $pos + $-[0], $pos + $+[0] );
660             $self->note( 'version_number', $pos + $-[1], $pos + $+[1] );
661             $version = $+{version_number};
662             if ( defined $+{version_later} ) {
663             $self->note( 'version_later', $pos + $-[2], $pos + $+[2] );
664             $suffix = '_or_later';
665             }
666             if ( defined $+{version_only} ) {
667             $self->note( 'version_only', $pos + $-[4], $pos + $+[4] );
668             $suffix = '_only';
669             }
670             if ( defined $+{version_of} ) {
671             $self->note( 'version_of', $pos + $-[5], $pos + $+[5] );
672             $pos += $+[0];
673             @grant_types = @VERSIONED;
674             }
675             else {
676             $version = '';
677             }
678             }
679              
680             # scan for name
681             for my $id (@NAMES) {
682             if ( substr( $string, $pos ) =~ $RE{"NAME_$id"} ) {
683             $match{$id}{name}{ $pos + $-[0] }
684             = $self->note( "name($id)", $pos + $-[0], $pos + $+[0] );
685             }
686             }
687              
688             # pick longest matched license name
689             # TODO: include all of most specific type when more are longest
690             my @names = sort {
691             $match{$a}{name}{$pos}->[1] <=> $match{$b}{name}{$pos}->[1]
692             }
693             grep {
694             $match{$_}
695             and $match{$_}{name}
696             and $match{$_}{name}{$pos}
697             } @grant_types;
698             my $name = $names[-1];
699             if ( $name
700             and $match{$name}{name}{$pos}
701             and !$self->contains_tag( $pos, $match{$name}{name}{$pos}->[1] )
702             and ( !$skip_stepwise or $L_grant_atomic_incomplete{$name} ) )
703             {
704             my $pos_end = $pos = $match{$name}{name}{$pos}->[1];
705              
706             # may include version
707             if ( !$version and grep { $_ eq $name } @VERSIONED ) {
708             substr( $string, $pos ) =~ $RE{ANCHORLEFT_NAMED_version};
709             if ( defined $+{version_number} ) {
710             $self->note(
711             'version',
712             $pos + $-[0], $pos + $+[0]
713             );
714             $self->note(
715             'version_number',
716             $pos + $-[1], $pos + $+[1]
717             );
718             $version = $+{version_number};
719             if ( $+{version_later} ) {
720             $self->note(
721             'version_later',
722             $pos + $-[2], $pos + $+[2]
723             );
724             $suffix = '_or_later';
725             }
726             if ( defined $+{version_only} ) {
727             $self->note(
728             'version_only',
729             $pos + $-[4], $pos + $+[4]
730             );
731             $suffix = '_only';
732             }
733             $pos_end = $pos + $+[0];
734             }
735             }
736             elsif ( !$version and grep { $_ eq $name } @SINGLEVERSION ) {
737             substr( $string, $pos )
738             =~ $RE{ANCHORLEFT_NAMED_version_later};
739             if ( defined $+{version_later} ) {
740             $self->note(
741             'version_later',
742             $pos + $-[1], $pos + $+[1]
743             );
744             $suffix = '_or_later';
745             $pos_end = $pos + $+[0];
746             }
747             }
748             if ($version) {
749             $version =~ tr/./_/;
750             $version =~ s/(?:_0)+$//;
751             $name .= "_$version";
752             }
753             if ($suffix) {
754             my $latername = "$name$suffix";
755             $grant{$latername} = $self->note(
756             Grant->new(
757             %{ $self->name_and_desc($latername) },
758             begin => $licensed_under->[0], end => $pos_end
759             )
760             );
761             next LICENSED_UNDER if grep { $grant{$_} } @NAMES;
762             }
763             $grant{$name} = $self->note(
764             Grant->new(
765             %{ $self->name_and_desc($name) },
766             begin => $licensed_under->[0], end => $pos_end
767             )
768             );
769             }
770             }
771              
772             # GNU oddities
773             if ( grep { $match{$_}{name} } @agpl, @gpl, @lgpl ) {
774             $log->trace('scan for GNU oddities');
775              
776             # address in AGPL/GPL/LGPL
777             while ( $string =~ /$RE{TRAIT_addr_fsf}/g ) {
778             for my $id (
779             qw(addr_fsf_franklin_steet addr_fsf_mass addr_fsf_temple))
780             {
781             if ( defined $+{$id} ) {
782             $self->tag(
783             Flaw->new(
784             %{ $self->name_and_desc($id) },
785             begin => $-[0], end => $+[0]
786             )
787             );
788             }
789             }
790             }
791             }
792              
793             # exceptions
794             # TODO: conditionally limit to AGPL/GPL/LGPL
795             for my $id (@EXCEPTIONS) {
796             if ( $string =~ $RE{"EXCEPTION_$id"} ) {
797             $self->tag(
798             Exception->new(
799             %{ $self->name_and_desc($id) },
800             begin => $-[0], end => $+[0]
801             )
802             );
803             }
804             }
805              
806             # oddities
807             $log->trace('scan for oddities');
808              
809             # generated file
810             if ( $string =~ $RE{TRAIT_generated} ) {
811             $self->tag(
812             Flaw->new(
813             %{ $self->name_and_desc('generated') },
814             begin => $-[0], end => $+[0]
815             )
816             );
817             }
818              
819             # multi-licensing
820             my @multilicenses;
821              
822             # LGPL, dual-licensed
823             # FIXME: add test covering this pattern
824             if ( grep { $match{$_}{name} } @lgpl ) {
825             $log->trace('scan for LGPL dual-license grant');
826             if ( $string =~ $L{multi_1} ) {
827             $self->note( 'grant(multi#1)', $-[0], $+[0] );
828             push @multilicenses, 'lgpl', $1, $2;
829             }
830             }
831              
832             # GPL, dual-licensed
833             # FIXME: add test covering this pattern
834             if ( grep { $match{$_}{name} } @gpl ) {
835             $log->trace('scan for GPL dual-license grant');
836             if ( $string =~ $L{multi_2} ) {
837             $self->note( 'grant(multi#2)', $-[0], $+[0] );
838             push @multilicenses, 'gpl', $1, $2;
839             }
840             }
841              
842             $gen_license->(@multilicenses) if (@multilicenses);
843              
844             # LGPL
845             for ( grep { $match{$_}{name} } @lgpl ) {
846             $log->trace('scan for LGPL fulltext/grant');
847              
848             # LGPL, dual versions last
849             if ( $string =~ $L{lgpl_5} ) {
850              
851             # TODO: simplify, and require Regexp::Pattern::License v3.11.0
852             my $v2 = $+{version_number_2} // $-{version_number}[1] || next;
853              
854             $self->tag(
855             Grant->new(
856             name => "LGPL-$+{version_number} or LGPL-$v2",
857             desc => "LGPL (v$+{version_number} or v$v2)",
858             begin => $-[0], end => $+[0],
859             )
860             );
861             $match{ 'lgpl_' . $+{version_number} =~ tr/./_/r }{custom} = 1;
862             $match{ 'lgpl_' . $v2 =~ tr/./_/r }{custom} = 1;
863             $match{lgpl}{custom} = 1;
864             }
865             }
866              
867             # GPL or LGPL
868             if ( grep { $match{$_}{name} } @gpl ) {
869             $log->trace('scan for GPL or LGPL dual-license grant');
870             if ( $string =~ $L{gpl_7} ) {
871             $self->note( "grant(gpl#7)", $-[0], $+[0] );
872             $gen_license->(
873             'gpl', $-{version_number}[0], $-{version_later}[0],
874             'lgpl', $-{version_number}[1], $-{version_later}[1],
875             );
876             $match{gpl}{custom} = 1;
877             $match{lgpl}{custom} = 1;
878             }
879             }
880              
881             # Apache dual-licensed with GPL/BSD/MIT
882             if ( $match{apache}{name} ) {
883             $log->trace('scan for Apache license grant');
884             for ($string) {
885             if ( $string =~ $L{apache_1} ) {
886             $self->note( 'grant(apache#1)', $-[0], $+[0] );
887             $gen_license->(
888             'apache', $-{version_number}[0], $-{version_later}[0],
889             'gpl', $-{version_number}[1], $-{version_later}[1],
890             );
891             $match{ $patterns2id->( 'apache', $-{version_number}[0] ) }
892             {custom} = 1;
893             next;
894             }
895             if ( $string =~ $L{apache_2} ) {
896             $self->note( 'grant(apache#2)', $-[0], $+[0] );
897             $gen_license->(
898             'apache', $+{version_number}, $+{version_later},
899             $+{version_bsd} ? "bsd_$+{version_bsd}_clause" : ''
900             );
901             $match{ $patterns2id->( 'apache', $+{version_number} ) }
902             {custom} = 1;
903             next;
904             }
905             if ( $string =~ $L{apache_4} ) {
906             $self->note( 'grant(apache#4)', $-[0], $+[0] );
907             $gen_license->(
908             'apache', $+{version_number}, $+{version_later},
909             'mit',
910             );
911             $match{ $patterns2id->( 'apache', $+{version_number} ) }
912             {custom} = 1;
913             next;
914             }
915             }
916             }
917              
918             # FSFUL
919             # FIXME: add test covering this pattern
920             $log->trace('scan for FSFUL fulltext');
921             if ( !$fulltext{fsful}
922             and $string =~ $L{fsful} )
923             {
924             $self->tag(
925             Fulltext->new(
926             name => "FSFUL~$1",
927             desc => "FSF Unlimited ($1 derivation)",
928             begin => $-[0], end => $+[0],
929             )
930             );
931             $match{fsful}{custom} = 1;
932             }
933              
934             # FSFULLR
935             # FIXME: add test covering this pattern
936             $log->trace('scan for FSFULLR fulltext');
937             if ( !$fulltext{fsfullr}
938             and $string =~ $L{fsfullr} )
939             {
940             $self->tag(
941             Fulltext->new(
942             name => "FSFULLR~$1",
943             desc => "FSF Unlimited (with Retention, $1 derivation)",
944             begin => $-[0], end => $+[0],
945             )
946             );
947             $match{fsfullr}{custom} = 1;
948             }
949              
950             # usage
951             $log->trace('scan atomic for singleversion usage license grant');
952             for my $id (@USAGE) {
953             next if ( $match{$id}{custom} );
954             if ( !$grant{$id}
955             and ( $L_grant_stepwise_incomplete{$id} or $force_atomic )
956             and $string =~ $RE{"GRANT_$id"} )
957             {
958             if ( $self->contains_tag( $-[0], $+[0] ) ) {
959             $log->tracef( 'skip grant in covered range: %s', $id );
960             }
961             else {
962             $grant{$id} = $self->tag(
963             Grant->new(
964             %{ $self->name_and_desc($id) },
965             begin => $-[0], end => $+[0]
966             )
967             );
968             }
969             }
970              
971             if ( $grant{$id} ) {
972             $gen_license->( $id2patterns->($id) );
973              
974             # skip singleversion and unversioned equivalents
975             if ( $L{usage}{$id} ) {
976             $log->tracef( 'flagged license object: %s', $id );
977             $match{ $L{usage}{$id} }{custom} = 1;
978             if ( $L{series}{ $L{usage}{$id} } ) {
979             $log->tracef(
980             'flagged license object: %s',
981             $L{usage}{$id}
982             );
983             $match{ $L{series}{ $L{usage}{$id} } }{custom} = 1;
984             }
985             }
986             }
987             }
988              
989             # singleversion
990             $log->trace('scan atomic for singleversion license grant');
991             for my $id (@SINGLEVERSION) {
992             if ( !$fulltext{$id}
993             and !$grant{$id}
994             and !$match{$id}{custom}
995             and ( $L_grant_stepwise_incomplete{$id} or $force_atomic )
996             and $string =~ $RE{"GRANT_$id"} )
997             {
998             if ( $self->contains_tag( $-[0], $+[0] ) ) {
999             $log->tracef( 'skip grant in covered range: %s', $id );
1000             }
1001             else {
1002             $grant{$id} = $self->tag(
1003             Grant->new(
1004             %{ $self->name_and_desc($id) },
1005             begin => $-[0], end => $+[0]
1006             )
1007             );
1008             }
1009             }
1010              
1011             if ( $fulltext{$id} or $grant{$id} ) {
1012             $gen_license->( $id2patterns->($id) )
1013             unless ( $match{$id}{custom} );
1014              
1015             # skip unversioned equivalent
1016             if ( $L{series}{$id} ) {
1017             $log->tracef( 'flagged license object: %s', $id );
1018             $match{ $L{series}{$id} }{custom} = 1;
1019             }
1020             }
1021             }
1022              
1023             # versioned
1024             $log->trace('scan atomic for versioned license grant');
1025             for my $id (@VERSIONED) {
1026             next
1027             if $match{$id}{custom}
1028             or ( $fulltext{rpsl_1} and grep { $id eq $_ } qw(mpl python) )
1029             or $fulltext{$id};
1030             if ( !$grant{$id}
1031             and ( $L_grant_stepwise_incomplete{$id} or $force_atomic )
1032             and $RE{"GRANT_$id"}
1033             and $string =~ $RE{"GRANT_$id"} )
1034             {
1035             if ( $self->contains_tag( $-[0], $+[0] ) ) {
1036             $log->tracef( 'skip grant in covered range: %s', $id );
1037             }
1038             else {
1039             $grant{$id} = $self->tag(
1040             Grant->new(
1041             %{ $self->name_and_desc($id) },
1042             begin => $-[0], end => $+[0]
1043             )
1044             );
1045             }
1046             }
1047             if ( $grant{$id} ) {
1048             $gen_license->($id);
1049             }
1050             }
1051              
1052             # other
1053             # TODO: add @GROUP
1054             $log->trace('scan atomic for misc fulltext/grant');
1055             for my $id ( @UNVERSIONED, @COMBO ) {
1056             next
1057             if (not $fulltext{$id}
1058             and not $grant{$id}
1059             and not $L_grant_stepwise_incomplete{$id}
1060             and not $force_atomic )
1061             or ( $fulltext{caldera} and $id eq 'bsd' )
1062             or ( $fulltext{cube} and $id eq 'zlib' )
1063             or ( $fulltext{dsdp} and $id eq 'ntp' )
1064             or ( $fulltext{mit_cmu} and $id eq 'ntp_disclaimer' )
1065             or ( $fulltext{ntp_disclaimer} and $id eq 'ntp' );
1066              
1067             if ( !$fulltext{$id}
1068             and !$grant{$id}
1069             and $string =~ $RE{"GRANT_$id"} )
1070             {
1071             if ( $self->contains_tag( $-[0], $+[0] ) ) {
1072             $log->tracef( 'skip grant in covered range: %s', $id );
1073             }
1074             else {
1075             $grant{$id} = $self->tag(
1076             Grant->new(
1077             %{ $self->name_and_desc($id) },
1078             begin => $-[0], end => $+[0]
1079             )
1080             );
1081             }
1082             }
1083             if ( $fulltext{$id} or $grant{$id} ) {
1084             $gen_license->($id);
1085             }
1086             }
1087              
1088             # Expressions and exceptions contain DEP-5 or SPDX identifiers;
1089             # flaws contain non-SPDX notes.
1090             my ( $licenses, $exceptions, $flaws ) = $self->get_tags;
1091              
1092             my @expressions = map { $_->[1] } @$licenses;
1093             my @license = map { $_->[2] } @$licenses;
1094             $expr = join( ' and/or ', sort @expressions );
1095             $license = join( ' and/or ', sort @license );
1096             $expr ||= 'UNKNOWN';
1097             $license ||= 'UNKNOWN';
1098              
1099             if (@$exceptions) {
1100             $expr = "($expr)"
1101             if ( @expressions > 1 );
1102             $expr .= ' with ' . join(
1103             '_AND_',
1104             sort map { $_->[1] } @$exceptions
1105             ) . ' exception';
1106             }
1107             if (@$flaws) {
1108             $license .= ' [' . join(
1109             ', ',
1110             sort map { $_->[2] } @$flaws
1111             ) . ']';
1112             }
1113             $log->infof( 'resolved license expression: %s', $expr );
1114              
1115             return $self;
1116             }
1117              
1118             =item as_text
1119              
1120             Returns identified licensing patterns as a string,
1121             either structured as SPDX License Expressions,
1122             or with scheme-less naming as a short description.
1123              
1124             =cut
1125              
1126             method as_text ()
1127             {
1128             if ( $naming->list_schemes ) {
1129             $self->resolve
1130             unless $expr;
1131              
1132             return $expr;
1133             }
1134              
1135             $self->resolve
1136             unless $license;
1137              
1138             return $license;
1139             }
1140              
1141             =back
1142              
1143             =encoding UTF-8
1144              
1145             =head1 AUTHOR
1146              
1147             Jonas Smedegaard C<< >>
1148              
1149             =head1 COPYRIGHT AND LICENSE
1150              
1151             This program is based on the script "licensecheck" from the KDE SDK,
1152             originally introduced by Stefan Westerfeld C<< >>.
1153              
1154             Copyright © 2007, 2008 Adam D. Barratt
1155              
1156             Copyright © 2016-2023 Jonas Smedegaard
1157              
1158             Copyright © 2017-2022 Purism SPC
1159              
1160             This program is free software:
1161             you can redistribute it and/or modify it
1162             under the terms of the GNU Affero General Public License
1163             as published by the Free Software Foundation,
1164             either version 3, or (at your option) any later version.
1165              
1166             This program is distributed in the hope that it will be useful,
1167             but WITHOUT ANY WARRANTY;
1168             without even the implied warranty
1169             of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
1170             See the GNU Affero General Public License for more details.
1171              
1172             You should have received a copy
1173             of the GNU Affero General Public License along with this program.
1174             If not, see .
1175              
1176             =cut
1177              
1178             1;