File Coverage

blib/lib/PLS/Server/Request/TextDocument/PublishDiagnostics.pm
Criterion Covered Total %
statement 159 233 68.2
branch 20 74 27.0
condition 9 35 25.7
subroutine 30 36 83.3
pod 0 7 0.0
total 218 385 56.6


line stmt bran cond sub pod time code
1             package PLS::Server::Request::TextDocument::PublishDiagnostics;
2              
3 11     11   77 use strict;
  11         22  
  11         447  
4 11     11   72 use warnings;
  11         22  
  11         985  
5              
6 11     11   80 use parent 'PLS::Server::Request';
  11         31  
  11         66  
7              
8 11     11   835 use Encode;
  11         31  
  11         1602  
9 11     11   96 use Fcntl ();
  11         22  
  11         278  
10 11     11   66 use File::Basename;
  11         23  
  11         982  
11 11     11   80 use File::Path;
  11         21  
  11         599  
12 11     11   74 use File::Spec;
  11         46  
  11         537  
13 11     11   90 use File::Temp;
  11         44  
  11         1142  
14 11     11   77 use IO::Async::Function;
  11         32  
  11         328  
15 11     11   62 use IO::Async::Loop;
  11         94  
  11         394  
16 11     11   65 use IO::Async::Process;
  11         36  
  11         400  
17 11     11   62 use List::Util qw(first);
  11         23  
  11         907  
18 11     11   76 use PPI;
  11         22  
  11         408  
19 11     11   70 use Path::Tiny;
  11         22  
  11         716  
20 11     11   7687 use Perl::Critic;
  11         16144885  
  11         738  
21 11     11   133 use Perl::Critic::PolicyParameter;
  11         34  
  11         532  
22 11     11   106 use URI;
  11         41  
  11         359  
23              
24 11     11   446 use PLS::Parser::Pod;
  11         34  
  11         391  
25 11     11   68 use PLS::Server::State;
  11         23  
  11         286  
26 11     11   57 use PLS::Util;
  11         23  
  11         45174  
27              
28             =head1 NAME
29              
30             PLS::Server::Request::TextDocument::PublishDiagnostics
31              
32             =head1 DESCRIPTION
33              
34             This is a message from the server to the client requesting that
35             diagnostics be published.
36              
37             These diagnostics currently include compilation errors and linting (using L<perlcritic>).
38              
39             =cut
40              
41             my $perlcritic_function = IO::Async::Function->new(code => \&run_perlcritic);
42             my $podchecker_function = IO::Async::Function->new(code => \&run_podchecker);
43              
44             my $loop = IO::Async::Loop->new();
45             $loop->add($perlcritic_function);
46             $loop->add($podchecker_function);
47              
48             sub new
49             {
50 2     2 0 28 my ($class, %args) = @_;
51              
52 2 50       108 return if (ref $PLS::Server::State::CONFIG ne 'HASH');
53              
54 2         68 my $uri = URI->new($args{uri});
55 2 50       511 return if (ref $uri ne 'URI::file');
56              
57 2         45 my $self = bless {
58             method => 'textDocument/publishDiagnostics',
59             params => {
60             uri => $uri->as_string,
61             diagnostics => []
62             },
63             notification => 1
64             },
65             $class;
66              
67 2         121 my (undef, $dir, $suffix) = File::Basename::fileparse($uri->file, qr/\.[^\.]*$/);
68              
69 2         1659 my $source = $uri->file;
70 2         372 my $text = PLS::Parser::Document->text_from_uri($uri->as_string);
71 2 50       27 $source = $text if (ref $text eq 'SCALAR');
72 2         43 my $version = PLS::Parser::Document::uri_version($uri->as_string);
73 2         20 my $client_has_version_support = $PLS::Server::State::CLIENT_CAPABILITIES->{textDocument}{publishDiagnostics}{versionSupport};
74 2 50 33     121 $self->{params}{version} = $version if (length $version and $client_has_version_support);
75              
76             # If closing, return empty list of diagnostics.
77 2 50       73 return Future->done($self) if $args{close};
78              
79 2         4 my @futures;
80              
81 2 50       44 push @futures, get_compilation_errors($source, $dir, $uri->file, $suffix) if ($PLS::Server::State::CONFIG->{syntax}{enabled});
82 2 50       268 push @futures, get_perlcritic_errors($source, $uri->file) if ($PLS::Server::State::CONFIG->{perlcritic}{enabled});
83 2 50       7392 push @futures, get_podchecker_errors($source) if ($PLS::Server::State::CONFIG->{podchecker}{enabled});
84              
85             return Future->wait_all(@futures)->then(
86             sub {
87 1     1   385 my $current_version = PLS::Parser::Document::uri_version($uri->as_string);
88              
89             # No version will be returned if the document has been closed.
90             # Since the only way we got here is if the document is open, we
91             # should return nothing, since any diagnostics we return will be from
92             # when the document was still open.
93 1 50       12 return Future->done(undef) unless (length $current_version);
94              
95             # If the document has been updated since the diagnostics were created,
96             # send nothing back. The next update will re-trigger the diagnostics.
97 1 50 33     33 return Future->done(undef) if (length $version and $current_version > $version);
98              
99 1         4 @{$self->{params}{diagnostics}} = map { $_->result } @_;
  1         55  
  2         26  
100              
101 1         13 return Future->done($self);
102             }
103 2         86 );
104             } ## end sub new
105              
106             sub get_compilation_errors
107             {
108 2     2 0 435 my ($source, $dir, $orig_path, $suffix) = @_;
109              
110 2         22 my $future = $loop->new_future();
111              
112 2         157 my $fh;
113             my $path;
114 2         0 my $temp;
115              
116 2 50       50 if (ref $source eq 'SCALAR')
117             {
118 2         30 $temp = eval { File::Temp->new(CLEANUP => 0, TEMPLATE => '.pls-tmp-XXXXXXXXXX', DIR => $dir) };
  2         174  
119 2 50       10625 $temp = eval { File::Temp->new(CLEANUP => 0) } if (ref $temp ne 'File::Temp');
  0         0  
120 2         18 $path = $temp->filename;
121 2     1   131 $future->on_done(sub { unlink $temp });
  1         69  
122              
123 2         85 my $source_text = Encode::encode('UTF-8', ${$source});
  2         195  
124              
125 2         641 print {$temp} $source_text;
  2         60  
126 2         172 close $temp;
127              
128 2 50       122 open $fh, '<', \$source_text or return [];
129             } ## end if (ref $source eq 'SCALAR'...)
130             else
131             {
132 0         0 $path = $source;
133 0 0       0 open $fh, '<', $path or return [];
134             }
135              
136 2         71 my $line_lengths = get_line_lengths($fh);
137              
138 2         20 close $fh;
139              
140 2         62 my $perl = PLS::Parser::Pod->get_perl_exe();
141 2         61 my $inc = PLS::Parser::Pod->get_clean_inc();
142 2         20 my $args = PLS::Parser::Pod->get_perl_args();
143 2   50     8 my @inc = map { "-I$_" } @{$inc // []};
  16         65  
  2         18  
144              
145 2         6 my @loadfile;
146              
147 2 50 33     89 if (not length $suffix or $suffix eq '.pl' or $suffix eq '.t' or $suffix eq '.plx')
    50 33        
      33        
148             {
149 0         0 @loadfile = (-c => $path);
150             }
151             elsif ($suffix eq '.pod')
152             {
153 0         0 $future->done();
154 0         0 return $future;
155             }
156             else
157             {
158 2         13 my ($relative, $module);
159              
160             # Try to get the path as relative to @INC. If we're successful,
161             # then we can convert it to a package name and import it using that name
162             # instead of the full path.
163 2         24 foreach my $inc_path (@{$inc})
  2         11  
164             {
165 16         416 my $rel = path($orig_path)->relative($inc_path);
166              
167 16 100       9777 if ($rel !~ /\.\./)
168             {
169 2         18 $module = $rel;
170 2         11 $relative = $rel;
171 2         19 $module =~ s/\.pm$//;
172 2         69 $module =~ s/\//::/g;
173 2         6 last;
174             } ## end if ($rel !~ /\.\./)
175             } ## end foreach my $inc_path (@{$inc...})
176              
177 2         9 my $code;
178 2         16 $path =~ s/'/\\'/g;
179              
180 2 50 33     19 if (length $module and length $relative)
181             {
182 2         21 $relative =~ s/'/\\'/g;
183              
184             # Load code using module name, but redirect Perl to the temp file
185             # when loading the file we are compiling.
186 2         24 $code = <<~ "EOF";
187             BEGIN
188             {
189             unshift \@INC, sub {
190             my (undef, \$filename) = \@_;
191              
192             if (\$filename eq '$relative')
193             {
194             if (open my \$fh, '<', '$path')
195             {
196             \$INC{\$filename} = '$orig_path';
197             return \$fh;
198             }
199             }
200              
201             return undef;
202             };
203              
204             require $module;
205             }
206             EOF
207             } ## end if (length $module and...)
208             else
209             {
210 0         0 $code = "BEGIN { require '$path' }";
211             }
212              
213 2         76 @loadfile = (-e => $code);
214             } ## end else[ if (not length $suffix...)]
215              
216 2         14 my @diagnostics;
217              
218             my $proc = IO::Async::Process->new(
219 2         36 command => [$perl, @inc, @loadfile, '--', @{$args}],
220             setup => [chdir => path($orig_path)->parent],
221             stderr => {
222             on_read => sub {
223 1     1   815 my ($stream, $buffref, $eof) = @_;
224              
225 1         3 while (${$buffref} =~ s/^(.*)\n//)
  1         14  
226             {
227 0         0 my $line = $1;
228              
229 0 0       0 next if $line =~ /syntax OK$/;
230              
231 0 0       0 if (my ($error, $file, $line_num, $area) = $line =~ /^(.+) at (.+?) line (\d+)(, .+)?/)
232             {
233 0         0 $line_num = int $line_num;
234 0 0       0 $file = $orig_path if ($file eq $path);
235              
236 0 0 0     0 if ($file ne $path and $file ne $orig_path)
237             {
238 0 0       0 $error .= " at $file line $line_num" if ($file ne '-e');
239 0         0 $line_num = 1;
240             }
241              
242 0 0       0 if (length $area)
243             {
244 0 0 0     0 if ($area =~ /^, near "/ and $area !~ /"$/)
245             {
246 0         0 $area .= "\n";
247              
248 0         0 while (${$buffref} =~ s/^(.*\n)//)
  0         0  
249             {
250 0         0 $area .= $1;
251 0 0       0 last if ($1 =~ /"$/);
252             }
253             } ## end if ($area =~ /^, near "/...)
254              
255 0         0 $error .= $area;
256             } ## end if (length $area)
257              
258 0         0 push @diagnostics,
259             {
260             range => {
261             start => {line => $line_num - 1, character => 0},
262             end => {line => $line_num - 1, character => $line_lengths->[$line_num]}
263             },
264             message => $error,
265             severity => 1,
266             source => 'perl',
267             };
268             } ## end if (my ($error, $file,...))
269              
270             } ## end while (${$buffref} =~ s/^(.*)\n//...)
271              
272 1         4 return 0;
273             }
274             },
275             stdout => {
276             on_read => sub {
277 1     1   833425 my ($stream, $buffref) = @_;
278              
279             # Discard STDOUT, otherwise it might interfere with the server execution.
280             # This can happen if there is a BEGIN block that prints to STDOUT.
281 1         4 ${$buffref} = '';
  1         18  
282 1         6 return 0;
283             }
284             },
285             on_finish => sub {
286 1     1   1162 $future->done(@diagnostics);
287             }
288 2         7 );
289              
290 2         1579 $loop->add($proc);
291              
292 2         33597 return $future;
293             } ## end sub get_compilation_errors
294              
295             sub get_perlcritic_errors
296             {
297 2     2 0 1316 my ($source, $path) = @_;
298              
299 2         191 my ($profile) = PLS::Util::resolve_workspace_relative_path($PLS::Server::State::CONFIG->{perlcritic}{perlcriticrc});
300              
301 2 50       22 if (not length $profile)
302             {
303 2         101 ($profile) = glob $PLS::Server::State::CONFIG->{perlcritic}{perlcriticrc};
304             }
305              
306 2 50 33     108 if (not length $profile or not -f $profile or not -r $profile)
      33        
307             {
308 2         29 undef $profile;
309             }
310              
311 2         73 my ($perltidyrc) = PLS::Util::resolve_workspace_relative_path($PLS::Server::State::CONFIG->{perltidy}{perltidyrc});
312              
313 2         156 return $perlcritic_function->call(args => [$profile, $source, $path, $perltidyrc]);
314             } ## end sub get_perlcritic_errors
315              
316             sub run_perlcritic
317             {
318 0     0 0 0 my ($profile, $source, $path, $perltidyrc) = @_;
319              
320 0         0 my $critic = Perl::Critic->new(-profile => $profile);
321 0         0 _set_perltidyrc_path_for_perltidy_policy($critic, $perltidyrc);
322              
323 0         0 my %args;
324 0 0       0 $args{filename} = $path if (ref $source eq 'SCALAR');
325 0         0 my $doc = PPI::Document->new($source, %args);
326 0         0 my @violations = eval { $critic->critique($doc) };
  0         0  
327              
328 0         0 my @diagnostics;
329              
330             # Mapping from perlcritic severity to LSP severity
331 0         0 my %severity_map = (
332             5 => 1,
333             4 => 1,
334             3 => 2,
335             2 => 3,
336             1 => 3
337             );
338              
339 0         0 foreach my $violation (@violations)
340             {
341 0         0 my $severity = $severity_map{$violation->severity};
342              
343 0         0 my $uri = URI->new();
344 0         0 $uri->scheme('https');
345 0         0 $uri->authority('metacpan.org');
346 0         0 $uri->path('pod/' . $violation->policy);
347              
348 0         0 push @diagnostics,
349             {
350             range => {
351             start => {line => $violation->line_number - 1, character => $violation->column_number - 1},
352             end => {line => $violation->line_number - 1, character => $violation->column_number + length($violation->source) - 1}
353             },
354             message => $violation->description,
355             code => $violation->policy,
356             codeDescription => {href => $uri->as_string},
357             severity => $severity,
358             source => 'perlcritic'
359             };
360             } ## end foreach my $violation (@violations...)
361              
362 0         0 return @diagnostics;
363             } ## end sub run_perlcritic
364              
365             sub get_line_lengths
366             {
367 2     2 0 23 my ($fh) = @_;
368              
369 2         7 my @line_lengths;
370              
371 2         38 while (my $line = <$fh>)
372             {
373 234         472 chomp $line;
374 234         737 $line_lengths[$.] = length $line;
375             }
376              
377 2         69 return \@line_lengths;
378             } ## end sub get_line_lengths
379              
380             sub get_podchecker_errors
381             {
382 0     0 0   my ($source) = @_;
383              
384 0           return $podchecker_function->call(args => [$source]);
385             }
386              
387             sub run_podchecker
388             {
389 0     0 0   my ($source) = @_;
390              
391 0 0         return unless (eval { require Pod::Checker; 1 });
  0            
  0            
392              
393 0           my $errors = '';
394 0 0         open my $ofh, '>', \$errors or return;
395 0 0         open my $ifh, '<', $source or return;
396              
397 0           my $line_lengths = get_line_lengths($ifh);
398 0           seek $ifh, 0, Fcntl::SEEK_SET;
399              
400 0           Pod::Checker::podchecker($ifh, $ofh);
401              
402 0           my @diagnostics;
403              
404 0           while ($errors =~ s/^(.*)\n//)
405             {
406 0           my $line = $1;
407              
408 0 0         if (my ($severity, $error, $line_num) = $line =~ /^\**\s*([A-Z]{3,}):\s*(.+) at line (\d+) in file/)
409             {
410 0 0         push @diagnostics,
411             {
412             range => {
413             start => {line => $line_num - 1, character => 0},
414             end => {line => $line_num - 1, character => $line_lengths->[$line_num]}
415             },
416             message => $error,
417             severity => $severity eq 'ERROR' ? 1 : 2,
418             source => 'podchecker',
419             };
420             } ## end if (my ($severity, $error...))
421             } ## end while ($errors =~ s/^(.*)\n//...)
422              
423 0           return @diagnostics;
424             } ## end sub run_podchecker
425              
426             sub _set_perltidyrc_path_for_perltidy_policy
427             {
428 0     0     my ($critic, $perltidyrc) = @_;
429              
430 0 0 0       if (not length $perltidyrc or not -f $perltidyrc)
431             {
432 0           return;
433             }
434              
435 0     0     my $policy = first { $_->isa('Perl::Critic::Policy::CodeLayout::RequireTidyCode') } $critic->config->policies;
  0            
436              
437 0 0         if (not $policy)
438             {
439 0           return;
440             }
441              
442 0     0     my ($parameter) = first { $_->get_name() eq 'perltidyrc' } map { Perl::Critic::PolicyParameter->new($_) } $policy->supported_parameters;
  0            
  0            
443              
444 0 0         if (not $parameter)
445             {
446 0           return;
447             }
448              
449 0           $parameter->parse_and_validate_config_value($policy, {perltidyrc => $perltidyrc});
450              
451 0           return;
452             } ## end sub _set_perltidyrc_path_for_perltidy_policy
453              
454             1;