File Coverage

blib/lib/Open/This.pm
Criterion Covered Total %
statement 154 175 88.0
branch 103 120 85.8
condition 26 38 68.4
subroutine 19 22 86.3
pod 4 4 100.0
total 306 359 85.2


line stmt bran cond sub pod time code
1 20     20   62681 use strict;
  20         51  
  20         819  
2 20     20   164 use warnings;
  20         39  
  20         2607  
3              
4             package Open::This;
5              
6             our $VERSION = '0.000035';
7              
8             our @ISA = qw(Exporter);
9             our @EXPORT_OK = qw(
10             maybe_get_url_from_parsed_text
11             editor_args_from_parsed_text
12             parse_text
13             to_editor_args
14             );
15              
16 20         128 use Module::Runtime qw(
17             is_module_name
18             module_notional_filename
19             require_module
20 20     20   11785 );
  20         43791  
21 20     20   12234 use Module::Util ();
  20         77898  
  20         902  
22 20     20   18284 use Path::Tiny qw( path );
  20         378927  
  20         2133  
23 20     20   13461 use Try::Tiny qw( catch try );
  20         53477  
  20         1495  
24 20     20   13114 use URI ();
  20         161324  
  20         63072  
25              
26             ## no critic (Subroutines::ProhibitExplicitReturnUndef)
27              
28             sub parse_text {
29 69     69 1 501780 my $text = join q{ }, @_;
30              
31 69 50       401 if ($text) {
32 69         659 $text =~ s/^\s+|\s+$//g;
33             }
34              
35             # Don't fail on a trailing colon which was accidentally pasted.
36 69 50       195 if ($text) {
37 69         172 $text =~ s{:\z}{};
38             }
39              
40 69 50       189 return undef if !$text;
41 69         249 my %parsed = ( original_text => $text );
42              
43 69         310 my ( $line, $col ) = _maybe_extract_line_number( \$text );
44 69         198 $parsed{line_number} = $line;
45 69 100       284 $parsed{column_number} = $col if $col;
46 69         196 $parsed{sub_name} = _maybe_extract_subroutine_name( \$text );
47              
48 69         230 _maybe_filter_text( \$text );
49              
50             # Is this an actual file.
51 69 100       319 if ( -e path($text) ) {
    100          
    100          
52 41         4015 $parsed{file_name} = $text;
53             }
54             elsif ( my $file_name = _maybe_git_diff_path($text) ) {
55 1         3 $parsed{file_name} = $file_name;
56             }
57             elsif ( $text =~ m{^[^/]+\.go$} ) {
58 2         6 my $file_name = _find_go_files($text);
59 2 100       11 $parsed{file_name} = $file_name if $file_name;
60             }
61              
62 69 100       413 if ( !exists $parsed{file_name} ) {
63 26 100       107 if ( my $bin = _which($text) ) {
64 1         34 $parsed{file_name} = $bin;
65 1         4 $text = $bin;
66             }
67             }
68              
69 69         255 $parsed{is_module_name} = is_module_name($text);
70              
71 69 100 100     1736 if ( !$parsed{file_name} && $text =~ m{\Ahttps?://}i ) {
72 4         12 $parsed{file_name} = _maybe_extract_file_from_url( \$text );
73             }
74              
75 69 100 100     1010 if ( !$parsed{file_name} && $parsed{is_module_name} ) {
76 15         88 $parsed{file_name} = _maybe_find_local_file($text);
77             }
78              
79             # This is a loadable module. Have this come after the local module checks
80             # so that we don't default to installed modules.
81 69 100 100     305 if ( !$parsed{file_name} && $parsed{is_module_name} ) {
82 4         20 my $found = Module::Util::find_installed($text);
83 4 100       2733 if ($found) {
84 2         9 $parsed{file_name} = $found;
85             }
86             }
87              
88 69 100       194 if ( !$parsed{line_number} ) {
89             $parsed{line_number} = _maybe_extract_line_number_via_sub_name(
90             $parsed{file_name},
91             $parsed{sub_name}
92 30         117 );
93             }
94              
95 205         599 my %return = map { $_ => $parsed{$_} }
96 69 100       263 grep { defined $parsed{$_} && $parsed{$_} ne q{} } keys %parsed;
  350         1338  
97              
98 69 100       581 return $return{file_name} ? \%return : undef;
99             }
100              
101             sub maybe_get_url_from_parsed_text {
102 0     0 1 0 my $err;
103             try {
104 0     0   0 require_module('Git::Helpers');
105             }
106             catch {
107 0     0   0 $err = $_;
108 0         0 };
109              
110 0 0       0 if ($err) {
111 0         0 warn 'This feature requires Git::Helpers to be installed';
112 0         0 return;
113             }
114              
115 0         0 my $parsed = shift;
116 0 0 0     0 return undef unless $parsed && $parsed->{file_name};
117              
118 0         0 my $url = Git::Helpers::https_remote_url();
119 0 0 0     0 return undef unless $url && $url->can('host');
120 0         0 $parsed->{remote_url} = $url;
121              
122 0         0 my $clone = $url->clone;
123 0         0 my @parts = $clone->path_segments;
124             push(
125             @parts,
126             'blob', Git::Helpers::current_branch_name(),
127             $parsed->{file_name}
128 0         0 );
129 0         0 $clone->path( join '/', @parts );
130 0 0       0 if ( $parsed->{line_number} ) {
131 0         0 $clone->fragment( 'L' . $parsed->{line_number} );
132             }
133              
134 0         0 $parsed->{remote_file_url} = $clone;
135 0         0 return $clone;
136             }
137              
138             sub to_editor_args {
139 36     36 1 3761733 return editor_args_from_parsed_text( parse_text(@_) );
140             }
141              
142             sub editor_args_from_parsed_text {
143 45     45 1 681295 my $parsed = shift;
144 45 100       157 return unless $parsed;
145              
146 44 100       188 die '$EDITOR has not been set' unless $ENV{EDITOR};
147              
148 43         73 my @args;
149              
150             # kate --line 11 --column 2 filename
151             # idea.sh --line 11 --column 2 filename
152 43 100 100     417 if ( $ENV{EDITOR} eq 'kate'
    100          
    100          
153             || $ENV{EDITOR}
154             =~ /^(idea|rubymine|pycharm|phpstorm|webstorm|goland|rider|clion|fleet|aqua|data(grip|spell)|appcode)/i
155             ) {
156             push @args, '--line', $parsed->{line_number}
157 23 100       102 if $parsed->{line_number};
158              
159             push @args, '--column', $parsed->{column_number}
160 23 100       71 if $parsed->{column_number};
161             }
162              
163             # code --goto filename:11:2
164             # codium --goto filename:11:2
165             elsif ( $ENV{EDITOR} =~ /^cod(e|ium)/i ) {
166 6         16 my $result = $parsed->{file_name};
167 6 100       15 if ( $parsed->{line_number} ) {
168 4         10 $result .= ":" . $parsed->{line_number};
169 4 100       14 if ( $parsed->{column_number} ) {
170 2         4 $result .= ":" . $parsed->{column_number};
171             }
172             }
173 6         69 return ( '--goto', $result );
174             }
175              
176             # See https://vi.stackexchange.com/questions/18499/can-i-open-a-file-at-an-arbitrary-line-and-column-via-the-command-line
177             # nvim +'call cursor(11,2)' filename
178             # vi +'call cursor(11,2)' filename
179             # vim +'call cursor(11,2)' filename
180             elsif ( exists $parsed->{column_number} ) {
181 2 100 33     23 if ( $ENV{EDITOR} eq 'nvim'
      66        
182             || $ENV{EDITOR} eq 'vi'
183             || $ENV{EDITOR} eq 'vim' ) {
184             @args = sprintf(
185             '+call cursor(%i,%i)',
186             $parsed->{line_number},
187             $parsed->{column_number},
188 1         9 );
189             }
190              
191             # nano +11,2 filename
192 2 100       9 if ( $ENV{EDITOR} eq 'nano' ) {
193             @args = sprintf(
194             '+%i,%i',
195             $parsed->{line_number},
196             $parsed->{column_number},
197 1         7 );
198             }
199             }
200              
201             else {
202             # emacs +11 filename
203             # nano +11 filename
204             # nvim +11 filename
205             # pico +11 filename
206             # vi +11 filename
207             # vim +11 filename
208 12 100       38 push @args, '+' . $parsed->{line_number} if $parsed->{line_number};
209             }
210              
211 37         339 return ( @args, $parsed->{file_name} );
212             }
213              
214             sub _maybe_extract_line_number {
215 80     80   564089 my $text = shift; # scalar ref
216              
217             # Ansible: ": line 14, column 16,"
218 80 100       306 if ( $$text =~ s{ line (\d+).*, column (\d+).*}{} ) {
219 4         32 return $1, $2;
220             }
221              
222             # Find a line number
223             # lib/Foo/Bar.pm line 222.
224              
225 76 100       293 if ( $$text =~ s{ line (\d+).*}{} ) {
226 15         72 return $1;
227             }
228              
229             # mvn test output
230             # lib/Open/This.pm:[17,3]
231 61 100       211 if ( $$text =~ s{ (\w) : \[ (\d+) , (\d+) \] }{$1}x ) {
232 1         5 return $2, $3;
233             }
234              
235             # ripgrep (rg --vimgrep)
236             # ./lib/Open/This.pm:17:3
237 60 100       440 if ( $$text =~ s{(\w):(\d+):(\d+).*}{$1} ) {
238 11         102 return $2, $3;
239             }
240              
241             # git-grep (don't match on ::)
242             # lib/Open/This.pm:17
243 49 100       206 if ( $$text =~ s{(\w):(\d+)\b}{$1} ) {
244 12         66 return $2;
245             }
246              
247             # Github links: foo/bar.go#L100
248             # Github links: foo/bar.go#L100-L110
249             # In this case, discard everything after the matching line number as well.
250 37 100       143 if ( $$text =~ s{(\w)#L(\d+)\b.*}{$1} ) {
251 5         20 return $2;
252             }
253              
254             # git-grep contextual match
255             # lib/Open/This.pm-17-
256 32 100       144 if ( $$text =~ s{(\w)-(\d+)\-{0,1}\z}{$1} ) {
257 2         7 return $2;
258             }
259              
260 30         107 return undef;
261             }
262              
263             sub _maybe_filter_text {
264 69     69   117 my $text = shift;
265              
266             # Ansible: The error appears to be in '/path/to/file':
267 69 100       231 if ( $$text =~ m{'(.*)'} ) {
268 4         12 my $maybe_file = $1;
269 4 50       197 $$text = $maybe_file if -e $maybe_file;
270             }
271             }
272              
273             sub _maybe_extract_subroutine_name {
274 73     73   5100 my $text = shift; # scalar ref
275              
276 73 100       341 if ( $$text =~ s{::(\w+)\(.*\)}{} ) {
277 9         39 return $1;
278             }
279 64         182 return undef;
280             }
281              
282             sub _maybe_extract_line_number_via_sub_name {
283 30     30   57 my $file_name = shift;
284 30         50 my $sub_name = shift;
285              
286 30 100 100     332 if ( $file_name && $sub_name && open( my $fh, '<', $file_name ) ) {
      66        
287 5         7 my $line_number = 1;
288 5         96 while ( my $line = <$fh> ) {
289 21 100       103 if ( $line =~ m{sub $sub_name\b} ) {
290 5         74 return $line_number;
291             }
292 16         31 ++$line_number;
293             }
294             }
295             }
296              
297             sub _maybe_extract_file_from_url {
298 4     4   7 my $text = shift;
299              
300 4         11 require_module('URI');
301 4         88 my $uri = URI->new($$text);
302              
303 4         9016 my @parts = $uri->path_segments;
304              
305             # Is this a GitHub(ish) URL?
306 4 50       392 if ( $parts[3] eq 'blob' ) {
307 4         25 my $file = path( @parts[ 5 .. scalar @parts - 1 ] );
308              
309 4 50       183 return unless $file->is_file;
310              
311 4         160 $file = $file->stringify;
312              
313 4 50 66     34 if ( $uri->fragment && $uri->fragment =~ m{\A[\d]\z} ) {
314 0         0 $file .= ':' . $uri->fragment;
315             }
316 4 50       66 return $file if $file;
317             }
318             }
319              
320             sub _maybe_find_local_file {
321 17     17   1810 my $text = shift;
322 17         60 my $possible_name = module_notional_filename($text);
323             my @dirs
324             = exists $ENV{OPEN_THIS_LIBS}
325             ? split m{,}, $ENV{OPEN_THIS_LIBS}
326 17 100       423 : ( 'lib', 't/lib' );
327              
328 17         42 for my $dir (@dirs) {
329 25         1299 my $path = path( $dir, $possible_name );
330 25 100       932 if ( $path->is_file ) {
331 13         534 return "$path";
332             }
333             }
334 4         185 return undef;
335             }
336              
337             sub _maybe_git_diff_path {
338 28     28   4639 my $file_name = shift;
339              
340 28 100       154 if ( $file_name =~ m|^[ab]/(.+)$| ) {
341 3 100       7 if ( -e path($1) ) {
342 1         60 return $1;
343             }
344             }
345              
346 27         263 return undef;
347             }
348              
349             # search for binaries in path
350              
351             sub _which {
352 29     29   627142 my $maybe_file = shift;
353 29 50       77 return unless $maybe_file;
354              
355 29         168 require_module('File::Spec');
356              
357             # we only want binary names here -- no paths
358 29         2045 my ( $volume, $dir, $file ) = File::Spec->splitpath($maybe_file);
359 29 100       146 return if $dir;
360              
361 20         355 my @path = File::Spec->path;
362 20         69 for my $path (@path) {
363 152         4620 my $abs = path( $path, $file );
364 152 100       5251 return $abs->stringify if $abs->is_file;
365             }
366              
367 18         427 return;
368             }
369              
370             sub _find_go_files {
371 3     3   834 my $text = shift;
372 3   100     13 my $threshold_init = shift || 5000;
373              
374 3         5 my $file_name;
375 3         9 my $iter = path('.')->iterator( { recurse => 1 } );
376 3         192 my $threshold = $threshold_init;
377 3         7 while ( my $path = $iter->() ) {
378 280 100       23672 next unless $path->is_file; # dirs will never match anything
379 193 100       1804 ( $threshold, $file_name ) = ( 0, "$path" )
380             if $path->basename eq $text;
381 193 100       3981 last if --$threshold == 0;
382             }
383 3 100 66     76 if ( $threshold == 0 && !$file_name ) {
384 1         15 warn "Only $threshold_init files searched recursively";
385             }
386 3         50 return $file_name;
387             }
388              
389             # ABSTRACT: Try to Do the Right Thing when opening files
390             1;
391              
392             __END__