File Coverage

blib/lib/Code/Quality.pm
Criterion Covered Total %
statement 67 93 72.0
branch 15 34 44.1
condition 0 6 0.0
subroutine 20 21 95.2
pod 3 6 50.0
total 105 160 65.6


line stmt bran cond sub pod time code
1             package Code::Quality;
2              
3 2     2   70823 use 5.020000;
  2         16  
4 2     2   10 use strict;
  2         4  
  2         51  
5 2     2   12 use warnings;
  2         4  
  2         58  
6 2     2   1172 use utf8;
  2         28  
  2         9  
7 2     2   65 use re '/s';
  2         3  
  2         189  
8 2     2   811 use parent qw/Exporter/;
  2         685  
  2         9  
9              
10             =encoding utf-8
11              
12             =head1 NAME
13              
14             Code::Quality - use static analysis to compute a "code quality" metric for a program
15              
16             =head1 SYNOPSIS
17              
18             use v5.20;
19             use Code::Quality;
20             # code to test (required)
21             my $code = ...;
22             # reference code to compare against (optional)
23             my $reference = ...;
24              
25             my $warnings =
26             analyse_code
27             code => $code,
28             reference => $reference,
29             language => 'C';
30             if (defined $warnings) {
31             my $stars = star_rating_of_warnings $warnings;
32             say "Program is rated $stars stars"; # 3 is best, 1 is worst
33             my @errors = grep { $_->[0] eq 'error' } @$warnings;
34             if (@errors > 0) {
35             say 'Found ', scalar @errors, ' errors';
36             say "First error: $errors[0][1]";
37             }
38             } else {
39             say 'Failed to analyse code';
40             }
41              
42             =head1 DESCRIPTION
43              
44             Code::Quality runs a series of tests on a piece of source code to
45             compute a code quality metric. Each test returns a possibly empty list
46             of warnings, that is potential issues present in the source code. This
47             list of warnings can then be turned into a star rating: 3 stars for
48             good code, 2 stars for acceptable code, and 1 stars for dubious code.
49              
50             =head2 Warnings
51              
52             A warning is an arrayref C<< [type, message, row, column] >>, where
53             the first two entries are mandatory and the last two can be either
54             both present or both absent.
55             The type is one of C<< qw/error warning info/ >>.
56              
57             Four-element warnings correspond to ACE code editor annotations.
58             Two-element warnings apply to the entire document, not a specific
59             place in the code.
60              
61             =head2 Tests
62              
63             A test is a function that takes key-value arguments:
64              
65             B(code => I<$code>, language => I<$language>, [reference => I<$reference>])
66              
67             Here I<$code> is the code to be tested, I<$language> is the
68             programming language, and I<$reference> is an optional reference
69             source code to compare I<$code> against.
70              
71             Each test returns undef if the test failed (for example, if the test
72             cannot be applied to this programming language), and an arrayref of
73             warnings otherwise.
74              
75             Most tests have several configurable parameters, which come from
76             global variables. The documentation of each test mentions the global
77             variables that affect its operations. C can be used to run a
78             test with special configuration once, without affecting other code:
79              
80             {
81             local $Code::Quality::bla_threshold = 5;
82             test_bla code => $code, language => 'C';
83             }
84              
85             =cut
86              
87             our $VERSION = '0.001002';
88             our @EXPORT = qw/analyse_code star_rating_of_warnings/;
89             our @EXPORT_OK = (@EXPORT, qw/test_lines test_clang_tidy/);
90             our %EXPORT_TAGS = (default => \@EXPORT, all => \@EXPORT_OK);
91              
92             # set this to a "Test::More::diag"-like function to get debug output
93             our $DEBUG;
94              
95 2     2   281 use Carp qw/carp croak/;
  2         4  
  2         165  
96 2     2   1877 use Cpanel::JSON::XS qw/encode_json/;
  2         11374  
  2         115  
97 2     2   1398 use File::Temp qw//;
  2         38652  
  2         78  
98 2     2   30 use List::Util qw/reduce any/;
  2         4  
  2         2835  
99              
100 8 50   8 0 30 sub diag { $DEBUG->(@_) if defined $DEBUG }
101              
102             sub remove_empty_lines {
103 16     16 0 26 my ($code) = @_;
104 16         254 $code =~ s/\n\s*/\n/g; # remove empty lines
105 16         53 $code =~ s/^\s*//g; # remove leading whitespace
106 16         34 return $code;
107             }
108              
109             our $warn_code_is_long = [warning => 'A shorter solution is possible'];
110             our $warn_code_is_very_long = [error => 'A significantly shorter solution is possible'];
111              
112             # a criterion is a pair [abs, rel]. a program matches a criterion if
113             # the absolute loc difference is at most abs AND the relative loc
114             # difference is at most rel. These criteria are used to categorise
115             # code as "short", "long", or "very long".
116              
117             # code is considered short if one of these criteria match
118             our @short_code_criteria = (
119             [1e9, 0.3],
120             [20, 0.5],
121             [10, 1],
122             );
123              
124             # code is considered long if one of these criteria match, and none of
125             # the above do
126             our @long_code_criteria = (
127             [1e9, 0.5],
128             [20, 1],
129             [10, 2],
130             );
131              
132             # code is considered very long if none of the criteria above match
133              
134             =head3 test_lines
135              
136             This test counts non-empty lines in both the code and the reference.
137             If the code is significantly longer than the reference, it returns a warning.
138             If the code is much longer, it returns an error.
139             Otherwise it returns an empty arrayref.
140              
141             The thresholds for raising a warning/error are available in the source
142             code, see global variables C<@short_code_criteria> and
143             C<@long_code_criteria>.
144              
145             This test fails if no reference is provided, but is language-agnostic
146              
147             =cut
148              
149             sub test_lines {
150 8     8 1 2676 my %args = @_;
151 8         17 my $user_solution = $args{code};
152 8         13 my $official_solution = $args{reference};
153 8 50       18 return unless defined $official_solution;
154              
155 8         28 $user_solution = remove_empty_lines($user_solution . "\n");
156 8         19 $official_solution = remove_empty_lines($official_solution . "\n");
157              
158             # Count number of lines of code from both solutions.
159 8         87 my $loc_user_solution = () = $user_solution =~ /\n/g;
160 8         56 my $loc_official_solution = () = $official_solution =~ /\n/g;
161 8 50       28 return if $loc_official_solution == 0;
162              
163 8         13 my $loc_absolute_diff = $loc_user_solution - $loc_official_solution;
164 8         18 my $loc_relative_diff = $loc_absolute_diff / $loc_official_solution;
165 8         53 diag "abs diff: $loc_absolute_diff, rel diff: $loc_relative_diff";
166             my $predicate = sub {
167 29 100   29   127 $loc_absolute_diff <= $_->[0] && $loc_relative_diff <= $_->[1]
168 8         2651 };
169              
170 8 100       30 return [] if any \&$predicate, @short_code_criteria;
171 4 100       12 return [$warn_code_is_long] if any \&$predicate, @long_code_criteria;
172 1         6 return [$warn_code_is_very_long]
173             }
174              
175             =head3 test_clang_tidy
176              
177             This test runs the
178             L static analyser
179             on the code and returns all warnings found.
180              
181             The clang-tidy checks in use are determined by two global variables,
182             each of which is a list of globs such as C. The checks in
183             C<@clang_tidy_warnings> produce warnings, while the checks in
184             C<@clang_tidy_errors> produce errors. There is also a hash
185             C<%clang_tidy_check_options> which contains configuration for the
186             checks.
187              
188             This test does not require a reference, but is limited to languages
189             that clang-tidy understands. This is controlled by the global variable
190             C<%extension_of_language>, which contains file extensions for the
191             supported languages.
192              
193             =cut
194              
195             our %extension_of_language = (
196             'C' => '.c',
197             'C++' => '.cpp',
198             );
199              
200             our @clang_tidy_warnings =
201             qw/clang-analyzer-deadcode.DeadStores
202             clang-analyzer-unix.*
203             clang-analyzer-valist.*
204             misc-*
205             modernize-*
206             performance-*
207             readability-*
208             -readability-braces-around-statements/;
209              
210             our @clang_tidy_errors =
211             qw/bugprone-*
212             clang-analyzer-core.*
213             clang-analyzer-cplusplus.*
214             clang-diagnostic-*/;
215              
216             our %clang_tidy_check_options = (
217             'readability-implicit-bool-conversion.AllowIntegerConditions' => 1,
218             );
219              
220             sub _clang_tidy_exists {
221             # does clang-tidy exist?
222             # run it with no arguments, see if exit code is 127
223 1     1   68600 system 'clang-tidy 2>/dev/null 1>/dev/null';
224 1         66 $? >> 8 != 127
225             }
226              
227             sub test_clang_tidy {
228 3     3 1 10 my %args = @_;
229 3         9 my $extension = $extension_of_language{uc $args{language}};
230 3 50       13 return unless defined $extension;
231              
232 0         0 my $fh = File::Temp->new(
233             TEMPLATE => 'code-qualityXXXXX',
234             TMPDIR => 1,
235             SUFFIX => $extension,
236             );
237 0 0       0 print $fh $args{code} or croak 'Failed to write code to temporary file';
238 0 0       0 close $fh or croak 'Failed to close temporary file';
239              
240 0         0 my $checks = join ',', '-*', @clang_tidy_warnings, @clang_tidy_errors;
241 0         0 my $errors = join ',', '-*', @clang_tidy_errors;
242 0         0 my @check_options;
243 0         0 while (my ($key, $value) = each %clang_tidy_check_options) {
244 0         0 push @check_options, { key => $key, value => $value }
245             }
246 0         0 my $config = encode_json +{
247             CheckOptions => \@check_options,
248             Checks => $checks,
249             WarningsAsErrors => $errors,
250             };
251              
252 0         0 my @output = qx,clang-tidy -config='$config' $fh 2>/dev/null,;
253 0         0 my $exit_code = $? >> 8; # this is usually the number of clang-tidy errors
254 0         0 my $signal = $? & 127;
255 0 0 0     0 if ($signal || ($exit_code == 127 && !_clang_tidy_exists)) {
      0        
256             # special case: exit code 127 could mean "127 errors found" or
257             # "clang-tidy not found"
258 0         0 carp "Failed to run clang-tidy, \$? is $?";
259             return
260 0         0 }
261              
262 0         0 my @warnings;
263 0         0 for my $line (@output) {
264 0 0       0 my ($row, $col, $type, $msg) =
265             $line =~ /$fh:(\d+):(\d+): (\w+): (.*)$/
266             or next;
267 0         0 chomp $msg;
268 0         0 $msg =~ s/,-warnings-as-errors//;
269 0 0       0 $type = 'info' if $type eq 'note';
270 0         0 push @warnings, [$type, $msg, $row, $col]
271             }
272             \@warnings
273 0         0 }
274              
275             =head3 analyse_code
276              
277             B runs every test above on the code, producing a
278             combined list of warnings. It fails (returns undef) if all tests fail.
279             The tests run by B are those in the global variable
280             C<@all_tests>, which is a list of coderefs.
281              
282             =cut
283              
284             our @all_tests = (
285             \&test_lines,
286             \&test_clang_tidy,
287             );
288              
289             sub analyse_code {
290             # arguments/return value are identical to those of individual tests
291 3     3 1 1293 my @test_args = @_;
292 3         9 my @test_results = map { $_->(@test_args) } @all_tests;
  6         17  
293             reduce {
294             # $a accumulates warnings so far, $b are warnings from current test
295 0 0   0   0 return $b unless defined $a;
296 0 0       0 push @$a, @$b if defined $b;
297 0         0 $a
298 3         26 } @test_results;
299             }
300              
301             =head2 Star rating
302              
303             B(I<$warnings>) is a subroutine that takes
304             the output of a test and computes the star rating as an integer. The
305             rating is undef if the test failed, 1 if the test returned at least
306             one error, 2 if the test returned at least one warning but no errors,
307             and 3 otherwise. So a program gets 3 stars if it only raises
308             informational messages, or no messages at all.
309              
310             =cut
311              
312             sub star_rating_of_warnings {
313 8     8 0 29 my ($warnings) = @_;
314 8 50       19 return unless defined $warnings;
315 8 100   4   30 return 1 if any { $_->[0] eq 'error' } @$warnings;
  4         13  
316 7 100   3   28 return 2 if any { $_->[0] eq 'warning' } @$warnings;
  3         11  
317 4         14 return 3;
318             }
319              
320             1;
321             __END__