File Coverage

blib/lib/Env/Dot/Functions.pm
Criterion Covered Total %
statement 123 126 97.6
branch 43 48 89.5
condition 13 17 76.4
subroutine 20 20 100.0
pod 5 5 100.0
total 204 216 94.4


line stmt bran cond sub pod time code
1             ## no critic (ValuesAndExpressions::ProhibitConstantPragma)
2             package Env::Dot::Functions;
3 15     15   800763 use strict;
  15         30  
  15         616  
4 15     15   87 use warnings;
  15         28  
  15         895  
5 15     15   254 use 5.010;
  15         52  
6              
7 15     15   87 use Exporter 'import';
  15         28  
  15         1635  
8             our @EXPORT_OK = qw(
9             get_dotenv_vars
10             interpret_dotenv_filepath_var
11             get_envdot_filepaths_var_name
12             extract_error_msg
13             create_error_msg
14             );
15             our %EXPORT_TAGS = (
16             'all' => [
17             qw(
18             get_dotenv_vars
19             interpret_dotenv_filepath_var
20             get_envdot_filepaths_var_name
21             extract_error_msg
22             create_error_msg
23             )
24             ],
25             );
26              
27 15     15   100 use Cwd qw( abs_path );
  15         69  
  15         1316  
28 15     15   802 use English qw( -no_match_vars ); # Avoids regex performance penalty in perl 5.18 and earlier
  15         1283  
  15         119  
29 15     15   6410 use File::Spec;
  15         32  
  15         429  
30 15     15   6036 use IO::File;
  15         98905  
  15         2418  
31 15     15   129 use Carp;
  15         64  
  15         1648  
32              
33             # ABSTRACT: Read environment variables from a .env file
34              
35             our $VERSION = '0.020';
36              
37             use constant {
38 15         41143 OPTION_FILE_TYPE => q{file:type},
39             OPTION_FILE_TYPE_PLAIN => q{plain},
40             OPTION_FILE_TYPE_SHELL => q{shell},
41             DEFAULT_OPTION_FILE_TYPE => q{shell},
42             OPTION_READ_FROM_PARENT => q{read:from_parent},
43             DEFAULT_OPTION_READ_FROM_PARENT => 0,
44             OPTION_READ_ALLOW_MISSING_PARENT => q{read:allow_missing_parent},
45             DEFAULT_OPTION_READ_ALLOW_MISSING_PARENT => 0,
46 15     15   103 };
  15         27  
47              
48             my %DOTENV_OPTIONS = (
49             OPTION_READ_FROM_PARENT() => 1,
50             OPTION_READ_ALLOW_MISSING_PARENT() => 1,
51             'file:type' => 1,
52             'var:allow_interpolate' => 1,
53             );
54             my %DOS_PLATFORMS = (
55             'dos' => 'MS-DOS/PC-DOS',
56             'os2' => 'OS/2',
57             'MSWin32' => 'Windows',
58             'cygwin' => 'Cygwin',
59             );
60              
61             sub get_dotenv_vars {
62 16     16 1 49 my (@dotenv_filepaths) = @_;
63              
64 16         29 my @vars;
65 16         43 foreach my $filepath ( reverse @dotenv_filepaths ) {
66 24 50       763 if ( -f $filepath ) {
67 24         96 push @vars, _read_dotenv_file_recursively($filepath);
68             }
69             else {
70 0         0 my ($err) = "File not found: '$filepath'";
71 0         0 croak create_error_msg($err);
72             }
73             }
74 13         72 return @vars;
75             }
76              
77             sub interpret_dotenv_filepath_var {
78 21     21 1 329842 my ($var_content) = @_;
79 21 50       106 if ( exists $DOS_PLATFORMS{$OSNAME} ) {
80 0         0 return split qr{;}msx, $var_content;
81             }
82             else {
83 21         243 return split qr{:}msx, $var_content;
84             }
85             }
86              
87             sub get_envdot_filepaths_var_name {
88 21     21 1 73 return q{ENVDOT_FILEPATHS};
89             }
90              
91             # Private subroutines
92              
93             sub _read_dotenv_file_recursively {
94 29     29   10789 my ($filepath) = @_;
95 29         1096 $filepath = abs_path($filepath);
96 29         143 my @rows = _read_dotenv_file($filepath);
97 29         109 my %r = _interpret_dotenv( $filepath, @rows );
98 27         56 my @these_vars = @{ $r{'vars'} };
  27         123  
99 27 100       123 if ( $r{'opts'}->{ OPTION_READ_FROM_PARENT() } ) {
100 7         21 my $parent_filepath = _get_parent_dotenv_filepath($filepath);
101 7 100       30 if ($parent_filepath) {
    100          
102 4         39 unshift @these_vars, _read_dotenv_file_recursively($parent_filepath);
103             }
104             elsif ( !$r{'opts'}->{ OPTION_READ_ALLOW_MISSING_PARENT() } ) {
105 2         8 my ($err) = "No parent .env file found for child file '$filepath'";
106 2         8 croak create_error_msg($err);
107             }
108             }
109 23         152 return @these_vars;
110             }
111              
112             # Follow directory hierarchy upwards until you find a .env file.
113             # If you don't, return undef.
114             # Otherwise return the path.
115             sub _get_parent_dotenv_filepath {
116 10     10   9629 my ($current_filepath) = @_;
117              
118 10         197 my ( $volume, $directories ) = File::Spec->splitpath($current_filepath);
119 10         81 my $parent_path = File::Spec->catpath( $volume, $directories );
120 10         21 my $parent_filepath;
121              
122 10   66     98 while ( defined $parent_path && $parent_path ne File::Spec->rootdir() ) {
123 22         1306 $parent_path = abs_path( File::Spec->catdir( $parent_path, File::Spec->updir ) );
124 22         300 $parent_filepath = File::Spec->catfile( $parent_path, '.env' );
125 22 100 66     1031 return $parent_filepath if ( defined $parent_path && -f $parent_filepath );
126             }
127 3         15 return;
128             }
129              
130             sub _interpret_dotenv {
131 36     36   22612 my ( $fp, @rows ) = @_;
132 36         231 my %options = (
133             OPTION_READ_FROM_PARENT() => DEFAULT_OPTION_READ_FROM_PARENT,
134             OPTION_READ_ALLOW_MISSING_PARENT() => DEFAULT_OPTION_READ_ALLOW_MISSING_PARENT,
135             'file:type' => DEFAULT_OPTION_FILE_TYPE,
136             'var:allow_interpolate' => 0,
137             ); # Options related to reading the file. Applied as they are read.
138 36         62 my @vars;
139 36         85 my $row_num = 1;
140 36         100 foreach (@rows) {
141             ## no critic (ControlStructures::ProhibitCascadingIfElse)
142             ## no critic (RegularExpressions::ProhibitComplexRegexes)
143 171 100       1509 if (
    100          
    100          
    100          
144             # This is envdot meta command
145             # The var:<value> options can only apply to one subsequent var row.
146             m{
147             ^ [[:space:]]{0,} [#]{1}
148             [[:space:]]{1,} envdot [[:space:]]{1,}
149             [(] (?<opts> [^)]{0,}) [)]
150             [[:space:]]{0,} $
151             }msx
152             )
153             {
154 18         96 my $opts = _interpret_opts( $LAST_PAREN_MATCH{opts} );
155 18         47 foreach my $key ( keys %{$opts} ) {
  18         90  
156 26 100       86 if ( !exists $DOTENV_OPTIONS{$key} ) {
157 5         13 my $err = "Unknown envdot option: '$key'";
158 5         19 croak create_error_msg( $err, $row_num, $fp );
159             }
160             }
161 13         33 $options{'var:allow_interpolate'} = 0;
162 13         23 foreach ( keys %{$opts} ) {
  13         36  
163 21         69 $options{$_} = $opts->{$_};
164             }
165             }
166             elsif (
167             # This is comment row
168             m{
169             ^ [[:space:]]{0,} [#]{1} .* $
170             }msx
171             )
172             {
173 25         42 1;
174             }
175             elsif (
176             # This is empty row
177             m{
178             ^ [[:space:]]{0,} $
179             }msx
180             )
181             {
182 3         5 1;
183             }
184             elsif (
185             # This is env var description
186             m{
187             ^ (?<name> [^=]{1,}) = (?<value> .*) $
188             }msx
189             )
190             {
191 124         943 my ( $name, $value ) = ( $LAST_PAREN_MATCH{name}, $LAST_PAREN_MATCH{value} );
192 124 100       434 if ( $options{'file:type'} eq OPTION_FILE_TYPE_SHELL ) {
    50          
193 119 100       11674 if (
194             $value =~ m{
195             ^
196             ['"]{1} (?<value> .*) ["']{1} # Get value from between quotes
197             (?: [;] [[:space:]]{0,} export [[:space:]]{1,} $name)? # optional
198             [[:space:]]{0,} # optional whitespace at the end
199             $
200             }msx
201             )
202             {
203 95         539 ($value) = $LAST_PAREN_MATCH{value};
204             }
205              
206             # "export" can also be at the start. Only for TYPE_SHELL
207 119 100       476 if ( $name =~ m{^ [[:space:]]{0,} export [[:space:]]{1,} }msx ) {
208 1         7 $name =~ m{
209             ^
210             [[:space:]]{0,} export [[:space:]]{1,} (?<name> .*)
211             $
212             }msx;
213 1         6 $name = $LAST_PAREN_MATCH{name};
214             }
215             }
216             elsif ( $options{'file:type'} eq OPTION_FILE_TYPE_PLAIN ) {
217 5         10 1; # document no-operation
218             }
219 124         403 my %opts = ( allow_interpolate => $options{'var:allow_interpolate'}, );
220 124         493 push @vars, { name => $name, value => $value, opts => \%opts, };
221 124         291 $options{'var:allow_interpolate'} = 0;
222             }
223             else {
224 1         3 my $err = "Invalid line: '$_'";
225 1         4 croak create_error_msg( $err, $row_num, $fp );
226             }
227 165         925 $row_num++;
228             }
229 30         174 return opts => \%options, vars => \@vars;
230             }
231              
232             sub _interpret_opts {
233 24     24   296073 my ($opts_str) = @_;
234 24         406 my @opts = split qr{ [[:space:]]{0,} [,] [[:space:]]{0,} }msx, $opts_str;
235 24         73 my %opts;
236 24         64 foreach (@opts) {
237             ## no critic (ControlStructures::ProhibitPostfixControls)
238 37         237 my ( $key, $val ) = split qr/=/msx;
239 37   100     169 $val = $val // 1;
240 37 100 66     209 $val = 1 if ( $val eq 'true' || $val eq 'True' );
241 37 100 66     168 $val = 0 if ( $val eq 'false' || $val eq 'False' );
242 37         137 $opts{$key} = $val;
243             }
244 24         80 return \%opts;
245             }
246              
247             sub _read_dotenv_file {
248 29     29   156 my ($filepath) = @_;
249 29         285 my $fh = IO::File->new();
250 29 50       1428 $fh->open(qq{< $filepath}) or croak "Error: Cannot open file '$filepath'";
251 29         1930 $fh->binmode(':encoding(UTF-8)');
252 29         68965 my @dotenv_rows = <$fh>;
253 29         619 chomp @dotenv_rows;
254 29 50       176 $fh->close or croak "Error: Cannot close file '$filepath'";
255 29         919 return @dotenv_rows;
256             }
257              
258             # Error messages:
259             # Message structure:
260             # <msg>! [line <num>] [file <filepath>]
261              
262             sub extract_error_msg {
263 9     9 1 240634 my ($msg) = @_;
264 9 100       21 if ( !$msg ) {
265 1         127 croak 'Parameter error: missing parameter \'msg\'';
266             }
267             ## no critic (RegularExpressions::ProhibitComplexRegexes)
268 8         70 my ( $err, $line, $filepath ) =
269             $msg =~ m/^ ([^!]{1,}) \! (?: \s line \s ([[:digit:]]{1,}) (?: \s file \s \'([^']{1,})\' )? )? .* $/msx;
270 8         29 return $err, $line, $filepath;
271             }
272              
273             sub create_error_msg {
274 15     15 1 4566 my ( $err, $line, $filepath ) = @_;
275 15 100       42 if ( !$err ) {
276 1         66 croak 'Parameter error: missing parameter \'err\'';
277             }
278 14 100 100     76 if ( !$line && $filepath ) {
279 1         90 croak 'Parameter error: missing parameter \'line\'';
280             }
281 13 100       1799 return "${err}!" . ( defined $line ? " line ${line}" : q{} ) . ( defined $filepath ? " file '${filepath}'" : q{} );
    100          
282             }
283              
284             1;
285              
286             __END__
287              
288             =pod
289              
290             =encoding UTF-8
291              
292             =head1 NAME
293              
294             Env::Dot::Functions - Read environment variables from a .env file
295              
296             =head1 VERSION
297              
298             version 0.020
299              
300             =head1 SYNOPSIS
301              
302             use Env::Dot::Functions qw( get_dotenv_vars interpret_dotenv_filepath_var );
303             # or
304             use Env::Dot::Functions ':all';
305              
306             =head1 DESCRIPTION
307              
308             =for :stopwords env dotenv filepath filepaths
309              
310             =head1 STATUS
311              
312             This module is currently being developed so changes in the API are possible,
313             though not likely.
314              
315             =for stopwords envdot
316              
317             This package just contains functions for use
318             in the main package L<Env::Dot> and in
319             the command line tool B<envdot>.
320              
321             =head1 FUNCTIONS
322              
323             No functions are automatically exported to the calling namespace.
324              
325             =head2 get_dotenv_vars(@)
326              
327             =for stopwords env
328              
329             Return all variables from the F<.env> file
330             as a list of hashes (name/value pairs).
331             This list is created in the same order the variables
332             are read from the files and may therefore contain
333             the same variable several times.
334              
335             The files, however, are read in reversed order, just like
336             paths in variable B<PATH> are used.
337              
338             Arguments:
339              
340             =over 8
341              
342             =item * filepaths, list of dotenv filepaths.
343              
344             =back
345              
346             If a file does not exist, we break the execution.
347              
348             =head2 interpret_dotenv_filepath_var( $filepaths )
349              
350             Return a list of file paths.
351              
352             =head2 get_envdot_filepaths_var_name
353              
354             =for stopwords env
355              
356             Return the name of the environment variable
357             which user can use to specify the paths of .env files.
358              
359             =head2 extract_error_msg
360              
361             Extract the elements of error message (exception): err, line and filepath.
362              
363             =head2 create_error_msg
364              
365             create an error message (exception) from the three elements: err, line and filepath.
366              
367             =head1 AUTHOR
368              
369             Mikko Koivunalho <mikkoi@cpan.org>
370              
371             =head1 COPYRIGHT AND LICENSE
372              
373             This software is copyright (c) 2023 by Mikko Koivunalho.
374              
375             This is free software; you can redistribute it and/or modify it under
376             the same terms as the Perl 5 programming language system itself.
377              
378             =cut