File Coverage

blib/lib/Open/This.pm
Criterion Covered Total %
statement 153 174 87.9
branch 101 118 85.5
condition 26 38 68.4
subroutine 19 22 86.3
pod 4 4 100.0
total 303 356 85.1


line stmt bran cond sub pod time code
1 11     11   7669 use strict;
  11         94  
  11         317  
2 11     11   56 use warnings;
  11         26  
  11         931  
3              
4             package Open::This;
5              
6             our $VERSION = '0.000033';
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 11         65 use Module::Runtime qw(
17             is_module_name
18             module_notional_filename
19             require_module
20 11     11   6166 );
  11         21084  
21 11     11   6882 use Module::Util ();
  11         36227  
  11         417  
22 11     11   10739 use Path::Tiny qw( path );
  11         201765  
  11         738  
23 11     11   6319 use Try::Tiny qw( catch try );
  11         24259  
  11         661  
24 11     11   6363 use URI ();
  11         53717  
  11         25778  
25              
26             ## no critic (Subroutines::ProhibitExplicitReturnUndef)
27              
28             sub parse_text {
29 60     60 1 22598 my $text = join q{ }, @_;
30              
31 60 50       189 if ($text) {
32 60         510 $text =~ s/^\s+|\s+$//g;
33             }
34              
35             # Don't fail on a trailing colon which was accidentally pasted.
36 60 50       142 if ($text) {
37 60         153 $text =~ s{:\z}{};
38             }
39              
40 60 50       187 return undef if !$text;
41 60         199 my %parsed = ( original_text => $text );
42              
43 60         172 my ( $line, $col ) = _maybe_extract_line_number( \$text );
44 60         154 $parsed{line_number} = $line;
45 60 100       146 $parsed{column_number} = $col if $col;
46 60         148 $parsed{sub_name} = _maybe_extract_subroutine_name( \$text );
47              
48 60         161 _maybe_filter_text( \$text );
49              
50             # Is this an actual file.
51 60 100       187 if ( -e path($text) ) {
    100          
    100          
52 41         2756 $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         7 my $file_name = _find_go_files($text);
59 2 100       10 $parsed{file_name} = $file_name if $file_name;
60             }
61              
62 60 100       327 if ( !exists $parsed{file_name} ) {
63 17 100       40 if ( my $bin = _which($text) ) {
64 1         30 $parsed{file_name} = $bin;
65 1         2 $text = $bin;
66             }
67             }
68              
69 60         195 $parsed{is_module_name} = is_module_name($text);
70              
71 60 100 100     1171 if ( !$parsed{file_name} && $text =~ m{\Ahttps?://}i ) {
72 4         12 $parsed{file_name} = _maybe_extract_file_from_url( \$text );
73             }
74              
75 60 100 100     173 if ( !$parsed{file_name} && $parsed{is_module_name} ) {
76 7         29 $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 60 100 100     257 if ( !$parsed{file_name} && $parsed{is_module_name} ) {
82 4         28 my $found = Module::Util::find_installed($text);
83 4 100       1979 if ($found) {
84 2         8 $parsed{file_name} = $found;
85             }
86             }
87              
88 60 100       141 if ( !$parsed{line_number} ) {
89             $parsed{line_number} = _maybe_extract_line_number_via_sub_name(
90             $parsed{file_name},
91             $parsed{sub_name}
92 29         107 );
93             }
94              
95 172         438 my %return = map { $_ => $parsed{$_} }
96 60 100       213 grep { defined $parsed{$_} && $parsed{$_} ne q{} } keys %parsed;
  306         976  
97              
98 60 100       482 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 7692 return editor_args_from_parsed_text( parse_text(@_) );
140             }
141              
142             sub editor_args_from_parsed_text {
143 36     36 1 60 my $parsed = shift;
144 36 100       100 return unless $parsed;
145              
146 35         55 my @args;
147              
148             # kate --line 11 --column 2 filename
149             # idea.sh --line 11 --column 2 filename
150 35 100 100     243 if ( $ENV{EDITOR} eq 'kate'
    100          
    100          
151             || $ENV{EDITOR}
152             =~ /^(idea|rubymine|pycharm|phpstorm|webstorm|goland|rider|clion|fleet|aqua|data(grip|spell)|appcode)/i
153             ) {
154             push @args, '--line', $parsed->{line_number}
155 15 100       48 if $parsed->{line_number};
156              
157             push @args, '--column', $parsed->{column_number}
158 15 100       46 if $parsed->{column_number};
159             }
160              
161             # code --goto filename:11:2
162             # codium --goto filename:11:2
163             elsif ( $ENV{EDITOR} =~ /^cod(e|ium)/i ) {
164 6         12 my $result = $parsed->{file_name};
165 6 100       13 if ( $parsed->{line_number} ) {
166 4         10 $result .= ":" . $parsed->{line_number};
167 4 100       10 if ( $parsed->{column_number} ) {
168 2         7 $result .= ":" . $parsed->{column_number};
169             }
170             }
171 6         49 return ( '--goto', $result );
172             }
173              
174             # See https://vi.stackexchange.com/questions/18499/can-i-open-a-file-at-an-arbitrary-line-and-column-via-the-command-line
175             # nvim +'call cursor(11,2)' filename
176             # vi +'call cursor(11,2)' filename
177             # vim +'call cursor(11,2)' filename
178             elsif ( exists $parsed->{column_number} ) {
179 2 100 33     28 if ( $ENV{EDITOR} eq 'nvim'
      66        
180             || $ENV{EDITOR} eq 'vi'
181             || $ENV{EDITOR} eq 'vim' ) {
182             @args = sprintf(
183             '+call cursor(%i,%i)',
184             $parsed->{line_number},
185             $parsed->{column_number},
186 1         8 );
187             }
188              
189             # nano +11,2 filename
190 2 100       12 if ( $ENV{EDITOR} eq 'nano' ) {
191             @args = sprintf(
192             '+%i,%i',
193             $parsed->{line_number},
194             $parsed->{column_number},
195 1         9 );
196             }
197             }
198              
199             else {
200             # emacs +11 filename
201             # nano +11 filename
202             # nvim +11 filename
203             # pico +11 filename
204             # vi +11 filename
205             # vim +11 filename
206 12 100       42 push @args, '+' . $parsed->{line_number} if $parsed->{line_number};
207             }
208              
209 29         243 return ( @args, $parsed->{file_name} );
210             }
211              
212             sub _maybe_extract_line_number {
213 71     71   14432 my $text = shift; # scalar ref
214              
215             # Ansible: ": line 14, column 16,"
216 71 100       237 if ( $$text =~ s{ line (\d+).*, column (\d+).*}{} ) {
217 4         20 return $1, $2;
218             }
219              
220             # Find a line number
221             # lib/Foo/Bar.pm line 222.
222              
223 67 100       226 if ( $$text =~ s{ line (\d+).*}{} ) {
224 7         32 return $1;
225             }
226              
227             # mvn test output
228             # lib/Open/This.pm:[17,3]
229 60 100       254 if ( $$text =~ s{ (\w) : \[ (\d+) , (\d+) \] }{$1}x ) {
230 1         10 return $2, $3;
231             }
232              
233             # ripgrep (rg --vimgrep)
234             # ./lib/Open/This.pm:17:3
235 59 100       248 if ( $$text =~ s{(\w):(\d+):(\d+).*}{$1} ) {
236 11         74 return $2, $3;
237             }
238              
239             # git-grep (don't match on ::)
240             # lib/Open/This.pm:17
241 48 100       215 if ( $$text =~ s{(\w):(\d+)\b}{$1} ) {
242 12         48 return $2;
243             }
244              
245             # Github links: foo/bar.go#L100
246             # Github links: foo/bar.go#L100-L110
247             # In this case, discard everything after the matching line number as well.
248 36 100       134 if ( $$text =~ s{(\w)#L(\d+)\b.*}{$1} ) {
249 5         23 return $2;
250             }
251              
252             # git-grep contextual match
253             # lib/Open/This.pm-17-
254 31 100       113 if ( $$text =~ s{(\w)-(\d+)\-{0,1}\z}{$1} ) {
255 2         8 return $2;
256             }
257              
258 29         72 return undef;
259             }
260              
261             sub _maybe_filter_text {
262 60     60   92 my $text = shift;
263              
264             # Ansible: The error appears to be in '/path/to/file':
265 60 100       183 if ( $$text =~ m{'(.*)'} ) {
266 4         12 my $maybe_file = $1;
267 4 50       89 $$text = $maybe_file if -e $maybe_file;
268             }
269             }
270              
271             sub _maybe_extract_subroutine_name {
272 64     64   5017 my $text = shift; # scalar ref
273              
274 64 100       210 if ( $$text =~ s{::(\w+)\(.*\)}{} ) {
275 9         39 return $1;
276             }
277 55         149 return undef;
278             }
279              
280             sub _maybe_extract_line_number_via_sub_name {
281 29     29   58 my $file_name = shift;
282 29         46 my $sub_name = shift;
283              
284 29 100 100     380 if ( $file_name && $sub_name && open( my $fh, '<', $file_name ) ) {
      66        
285 5         16 my $line_number = 1;
286 5         152 while ( my $line = <$fh> ) {
287 21 100       148 if ( $line =~ m{sub $sub_name\b} ) {
288 5         113 return $line_number;
289             }
290 16         50 ++$line_number;
291             }
292             }
293             }
294              
295             sub _maybe_extract_file_from_url {
296 4     4   6 my $text = shift;
297              
298 4         11 require_module('URI');
299 4         85 my $uri = URI->new($$text);
300              
301 4         8440 my @parts = $uri->path_segments;
302              
303             # Is this a GitHub(ish) URL?
304 4 50       374 if ( $parts[3] eq 'blob' ) {
305 4         20 my $file = path( @parts[ 5 .. scalar @parts - 1 ] );
306              
307 4 50       170 return unless $file->is_file;
308              
309 4         110 $file = $file->stringify;
310              
311 4 50 66     34 if ( $uri->fragment && $uri->fragment =~ m{\A[\d]\z} ) {
312 0         0 $file .= ':' . $uri->fragment;
313             }
314 4 50       80 return $file if $file;
315             }
316             }
317              
318             sub _maybe_find_local_file {
319 9     9   1927 my $text = shift;
320 9         31 my $possible_name = module_notional_filename($text);
321             my @dirs
322             = exists $ENV{OPEN_THIS_LIBS}
323             ? split m{,}, $ENV{OPEN_THIS_LIBS}
324 9 100       297 : ( 'lib', 't/lib' );
325              
326 9         24 for my $dir (@dirs) {
327 17         351 my $path = path( $dir, $possible_name );
328 17 100       693 if ( $path->is_file ) {
329 5         146 return "$path";
330             }
331             }
332 4         83 return undef;
333             }
334              
335             sub _maybe_git_diff_path {
336 19     19   1452 my $file_name = shift;
337              
338 19 100       70 if ( $file_name =~ m|^[ab]/(.+)$| ) {
339 3 100       13 if ( -e path($1) ) {
340 1         63 return $1;
341             }
342             }
343              
344 18         200 return undef;
345             }
346              
347             # search for binaries in path
348              
349             sub _which {
350 20     20   209 my $maybe_file = shift;
351 20 50       44 return unless $maybe_file;
352              
353 20         67 require_module('File::Spec');
354              
355             # we only want binary names here -- no paths
356 20         976 my ( $volume, $dir, $file ) = File::Spec->splitpath($maybe_file);
357 20 100       78 return if $dir;
358              
359 11         157 my @path = File::Spec->path;
360 11         47 for my $path (@path) {
361 71         6968 my $abs = path( $path, $file );
362 71 100       2790 return $abs->stringify if $abs->is_file;
363             }
364              
365 9         207 return;
366             }
367              
368             sub _find_go_files {
369 3     3   747 my $text = shift;
370 3   100     13 my $threshold_init = shift || 5000;
371              
372 3         4 my $file_name;
373 3         11 my $iter = path('.')->iterator( { recurse => 1 } );
374 3         199 my $threshold = $threshold_init;
375 3         8 while ( my $path = $iter->() ) {
376 274 100       31622 next unless $path->is_file; # dirs will never match anything
377 189 100       3172 ( $threshold, $file_name ) = ( 0, "$path" )
378             if $path->basename eq $text;
379 189 100       5130 last if --$threshold == 0;
380             }
381 3 100 66     72 if ( $threshold == 0 && !$file_name ) {
382 1         24 warn "Only $threshold_init files searched recursively";
383             }
384 3         55 return $file_name;
385             }
386              
387             # ABSTRACT: Try to Do the Right Thing when opening files
388             1;
389              
390             __END__