File Coverage

blib/lib/PLS/Server/Request/TextDocument/PublishDiagnostics.pm
Criterion Covered Total %
statement 146 204 71.5
branch 18 60 30.0
condition 9 32 28.1
subroutine 28 31 90.3
pod 0 7 0.0
total 201 334 60.1


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