File Coverage

blib/lib/Ledger/Parser.pm
Criterion Covered Total %
statement 187 201 93.0
branch 96 116 82.7
condition 42 55 76.3
subroutine 21 22 95.4
pod 3 3 100.0
total 349 397 87.9


line stmt bran cond sub pod time code
1             package Ledger::Parser;
2              
3             our $DATE = '2017-06-16'; # DATE
4             our $VERSION = '0.06'; # VERSION
5              
6 1     1   98131 use 5.010001;
  1         4  
7 1     1   5 use strict;
  1         2  
  1         16  
8 1     1   4 use utf8;
  1         3  
  1         5  
9 1     1   16 use warnings;
  1         2  
  1         23  
10              
11 1     1   4 use Carp;
  1         2  
  1         49  
12 1     1   729 use Math::BigFloat;
  1         15465  
  1         5  
13 1     1   27026 use Time::Moment;
  1         1011  
  1         59  
14              
15             use constant +{
16 1         2106 COL_TYPE => 0,
17              
18             COL_B_RAW => 1,
19              
20             COL_T_DATE => 1,
21             COL_T_EDATE => 2,
22             COL_T_WS1 => 3,
23             COL_T_STATE => 4,
24             COL_T_WS2 => 5,
25             COL_T_CODE => 6,
26             COL_T_WS3 => 7,
27             COL_T_DESC => 8,
28             COL_T_WS4 => 9,
29             COL_T_COMMENT => 10,
30             COL_T_NL => 11,
31             COL_T_PARSE_DATE => 12,
32             COL_T_PARSE_EDATE => 13,
33             COL_T_PARSE_TX => 14,
34              
35             COL_P_WS1 => 1,
36             COL_P_OPAREN => 2,
37             COL_P_ACCOUNT => 3,
38             COL_P_CPAREN => 4,
39             COL_P_WS2 => 5,
40             COL_P_AMOUNT => 6,
41             COL_P_WS3 => 7,
42             COL_P_COMMENT => 8,
43             COL_P_NL => 9,
44             COL_P_PARSE_AMOUNT => 10,
45              
46             COL_C_CHAR => 1,
47             COL_C_COMMENT => 2,
48             COL_C_NL => 3,
49              
50             COL_TC_WS1 => 1,
51             COL_TC_COMMENT => 2,
52             COL_TC_NL => 3,
53 1     1   6 };
  1         2  
54              
55             # note: $RE_xxx is capturing, $re_xxx is non-capturing
56             our $re_date = qr!(?:\d{4}[/-])?\d{1,2}[/-]\d{1,2}!;
57             our $RE_date = qr!(?:(\d{4})[/-])?(\d{1,2})[/-](\d{1,2})!;
58              
59             our $re_account_part = qr/(?:
60             [^\s:\[\(;]+?[ \t]??[^\s:\[\(;]*?
61             )+?/x; # don't allow double whitespace
62             our $re_account = qr/$re_account_part(?::$re_account_part)*/;
63             our $re_commodity = qr/[A-Z_]+[A-Za-z_]*|[\$£€¥]/;
64             our $re_amount = qr/(?:-?)
65             (?:$re_commodity)?
66             \s* (?:-?[0-9,]+\.?[0-9]*)
67             \s* (?:$re_commodity)?
68             /x;
69             our $RE_amount = qr/(-?)
70             ($re_commodity)?
71             (\s*) (-?[0-9,]+\.?[0-9]*)
72             (\s*) ($re_commodity)?
73             /x;
74              
75             sub new {
76 1     1 1 32155 my ($class, %attrs) = @_;
77              
78 1   50     15 $attrs{input_date_format} //= 'YYYY/MM/DD';
79 1   33     105 $attrs{year} //= (localtime)[5] + 1900;
80             #$attrs{strict} //= 0; # check valid account names
81              
82             # checking
83 1 50       12 $attrs{input_date_format} =~ m!\A(YYYY/MM/DD|YYYY/DD/MM)\z!
84             or croak "Invalid input_date_format: choose YYYY/MM/DD or YYYY/DD/MM";
85              
86 1         7 bless \%attrs, $class;
87             }
88              
89             sub _parse_date {
90 26     26   68 my ($self, $str) = @_;
91 26 50       150 return [400,"Invalid date syntax '$str'"] unless $str =~ /\A(?:$RE_date)\z/;
92              
93 26         43 my $tm;
94 26         44 eval {
95 26 50       62 if ($self->{input_date_format} eq 'YYYY/MM/DD') {
96             $tm = Time::Moment->new(
97             day => $3,
98             month => $2,
99             year => $1 || $self->{year},
100 26   66     255 );
101             } else {
102             $tm = Time::Moment->new(
103             day => $2,
104             month => $3,
105             year => $1 || $self->{year},
106 0   0     0 );
107             }
108             };
109 26 100       71 if ($@) { return [400, "Invalid date '$str': $@"] }
  1         5  
110 25         73 [200, "OK", $tm];
111             }
112              
113             sub _parse_amount {
114 32     32   70 my ($self, $str) = @_;
115 32 50       272 return [400, "Invalid amount syntax '$str'"]
116             unless $str =~ /\A(?:$RE_amount)\z/;
117              
118 32         115 my ($minsign, $commodity1, $ws1, $num, $ws2, $commodity2) =
119             ($1, $2, $3, $4, $5, $6);
120 32 100 100     95 if ($commodity1 && $commodity2) {
121 1         6 return [400, "Invalid amount '$str' (double commodity)"];
122             }
123 31         56 $num =~ s/,//g;
124 31 100       78 $num *= -1 if $minsign;
125 31 100 100     120 return [200, "OK", [
      100        
126             Math::BigFloat->new($num), # number
127             ($commodity1 || $commodity2) // '', # commodity
128             $commodity1 ? "B$ws1" : "A$ws2", # format: B(efore)|A(fter) + spaces
129             ]];
130             }
131              
132             # this routine takes the raw parsed lines and parse a transaction data from it.
133             # the _ledger_raw keys are used when we transport the transaction data outside
134             # and back in again, we want to be able to reconstruct the original
135             # transaction/posting lines if they are not modified exactly (for round-trip
136             # purposes).
137             sub _parse_tx {
138 20     20   36 my ($self, $parsed, $linum0) = @_;
139              
140 20         35 my $t_line = $parsed->[$linum0-1];
141 20         73 my $tx = {
142             date => $t_line->[COL_T_PARSE_DATE],
143             description => $t_line->[COL_T_DESC],
144             _ledger_raw => $t_line,
145             postings => [],
146             };
147 20 100       46 $tx->{edate} = $t_line->[COL_T_PARSE_EDATE] if $t_line->[COL_T_EDATE];
148              
149 20         33 my $linum = $linum0;
150 20         24 while (1) {
151 71 100       176 last if $linum++ > @$parsed-1;
152 51         77 my $line = $parsed->[$linum-1];
153 51         74 my $type = $line->[COL_TYPE];
154 51 100       92 if ($type eq 'P') {
    50          
155 46   50     101 my $oparen = $line->[COL_P_OPAREN] // '';
156 46 100       61 push @{ $tx->{postings} }, {
  46 100       266  
    100          
    100          
157             account => $line->[COL_P_ACCOUNT],
158             is_virtual => $oparen eq '(' ? 1 : $oparen eq '[' ? 2 : 0,
159             amount => $line->[COL_P_PARSE_AMOUNT] ?
160             $line->[COL_P_PARSE_AMOUNT][0] : undef,
161             commodity => $line->[COL_P_PARSE_AMOUNT] ?
162             $line->[COL_P_PARSE_AMOUNT][1] : undef,
163             _ledger_raw => $line,
164             };
165             } elsif ($type eq 'TC') {
166             # ledger associates a transaction comment with a posting that
167             # precedes it. if there is a transaction comment before any posting,
168             # we will stick it to the _ledger_raw_comments. otherwise, it will
169             # goes to each posting's _ledger_raw_comments.
170 5 100       7 if (@{ $tx->{postings} }) {
  5         14  
171 1         1 push @{ $tx->{postings}[-1]{_ledger_raw_comments} }, $line;
  1         3  
172             } else {
173 4         6 push @{ $tx->{_ledger_raw_comments} }, $line;
  4         9  
174             }
175             } else {
176 0         0 last;
177             }
178             }
179              
180             # some sanity checks for the transaction
181             CHECK:
182             {
183 20         32 my $num_postings = @{$tx->{postings}};
  20         24  
  20         40  
184 20 100       71 last CHECK if !$num_postings;
185 19 50 66     54 if ($num_postings == 1 && !defined(!$tx->{postings}[0]{amount})) {
186             #$self->_err("Posting amount cannot be null");
187             # ledger allows this
188 0         0 last CHECK;
189             }
190 19         29 my $num_nulls = 0;
191 19         28 my %bals; # key = commodity
192 19         27 for my $p (@{ $tx->{postings} }) {
  19         40  
193 46 100       18226 if (!defined($p->{amount})) {
194 15         23 $num_nulls++;
195 15         27 next;
196             }
197 31         118 $bals{$p->{commodity}} += $p->{amount};
198             }
199 19 100       886 last CHECK if $num_nulls == 1;
200 6 100       13 if ($num_nulls) {
201 1         3 $self->_err("There can only be one posting with null amount");
202             }
203 5         14 for (keys %bals) {
204             $self->_err("Transaction not balanced, " .
205             (-$bals{$_}) . ($_ ? " $_":"")." needed")
206 5 100       16 if $bals{$_} != 0;
    100          
207             }
208             }
209              
210 17         610 [200, "OK", $tx];
211             }
212              
213             sub _err {
214 9     9   569 my ($self, $msg) = @_;
215             croak join(
216             "",
217 9 50       11 @{ $self->{_include_stack} } ? "$self->{_include_stack}[0] " : "",
  9         116  
218             "line $self->{_linum}: ",
219             $msg
220             );
221             }
222              
223             sub _push_include_stack {
224 21     21   119 require Cwd;
225              
226 21         42 my ($self, $path) = @_;
227              
228             # included file's path is based on the main (topmost) file
229 21 50       33 if (@{ $self->{_include_stack} }) {
  21         63  
230 0         0 require File::Spec;
231             my (undef, $dir, $file) =
232 0         0 File::Spec->splitpath($self->{_include_stack}[-1]);
233 0         0 $path = File::Spec->rel2abs($path, $dir);
234             }
235              
236 21 50       646 my $abs_path = Cwd::abs_path($path) or return [400, "Invalid path name"];
237             return [409, "Recursive", $abs_path]
238 21 50       45 if grep { $_ eq $abs_path } @{ $self->{_include_stack} };
  0         0  
  21         75  
239 21         34 push @{ $self->{_include_stack} }, $abs_path;
  21         48  
240 21         61 return [200, "OK", $abs_path];
241             }
242              
243             sub _pop_include_stack {
244 12     12   18 my $self = shift;
245              
246 12 50       19 die "BUG: Overpopped _pop_include_stack" unless @{$self->{_include_stack}};
  12         30  
247 12         18 pop @{ $self->{_include_stack} };
  12         22  
248             }
249              
250             sub _init_read {
251 21     21   50 my $self = shift;
252              
253 21         56 $self->{_include_stack} = [];
254             }
255              
256             sub _read_file {
257 21     21   37 my ($self, $filename) = @_;
258 21 50       481 open my $fh, "<", $filename
259             or croak "Can't open file '$filename': $!";
260 21         93 binmode($fh, ":utf8");
261 21         69 local $/;
262 21         466 return ~~<$fh>;
263             }
264              
265             sub read_file {
266 21     21 1 40393 my ($self, $filename) = @_;
267 21         68 $self->_init_read;
268 21         58 my $res = $self->_push_include_stack($filename);
269 21 50       58 croak "Can't read '$filename': $res->[1]" unless $res->[0] == 200;
270 21         70 $res =
271             $self->_read_string($self->_read_file($filename));
272 12         42 $self->_pop_include_stack;
273 12         51 $res;
274             }
275              
276             sub read_string {
277 0     0 1 0 my ($self, $str) = @_;
278 0         0 $self->_init_read;
279 0         0 $self->_read_string($str);
280             }
281              
282             sub _read_string {
283 21     21   56 my ($self, $str) = @_;
284              
285 21         42 my $res = [];
286              
287 21         33 my $in_tx;
288              
289 21         96 my @lines = split /^/, $str;
290 21         55 local $self->{_linum} = 0;
291             LINE:
292 21         48 for my $line (@lines) {
293 148         252 $self->{_linum}++;
294              
295             # transaction is broken by an empty/all-whitespace line or a
296             # non-indented line. once we found a complete transaction, parse it.
297 148 100 66     614 if ($in_tx && ($line !~ /\S/ || $line =~ /^\S/)) {
      66        
298 17         45 my $parse_tx = $self->_parse_tx($res, $in_tx);
299 14 50       37 if ($parse_tx->[0] != 200) {
300 0         0 $self->_err($parse_tx->[1]);
301             }
302 14         31 $res->[$in_tx - 1][COL_T_PARSE_TX] = $parse_tx->[2];
303 14         28 $in_tx = 0;
304             }
305              
306             # blank line (B)
307 145 100       418 if ($line !~ /\S/) {
308 29         73 push @$res, [
309             'B',
310             $line, # COL_B_RAW
311             ];
312 29         57 next LINE;
313             }
314              
315             # transaction line (T)
316 116 100       272 if ($line =~ /^\d/) {
317 25 50       384 $line =~ m<^($re_date) # 1) actual date
318             (?: = ($re_date))? # 2) effective date
319             (?: (\s+) ([!*]) )? # 3) ws1 4) state
320             (?: (\s+) \(([^\)]+)\) )? # 5) ws2 6) code
321             (\s+) (\S.*?) # 7) ws3 8) desc
322             (?: (\s{2,}) ;(\S.+?) )? # 9) ws4 10) comment
323             (\R?)\z # 11) nl
324             >x
325             or $self->_err("Invalid transaction line syntax");
326 25         150 my $parsed_line = ['T', $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11];
327              
328 25         77 my $parse_date = $self->_parse_date($1);
329 25 100       65 if ($parse_date->[0] != 200) {
330 1         4 $self->_err($parse_date->[1]);
331             }
332 24         49 $parsed_line->[COL_T_PARSE_DATE] = $parse_date->[2];
333              
334 24 100       66 if ($2) {
335 1         3 my $parse_edate = $self->_parse_date($2);
336 1 50       4 if ($parse_edate->[0] != 200) {
337 0         0 $self->_err($parse_edate->[1]);
338             }
339 1         2 $parsed_line->[COL_T_PARSE_EDATE] = $parse_edate->[2];
340             }
341              
342 24         41 $in_tx = $self->{_linum};
343 24         40 push @$res, $parsed_line;
344 24         69 next LINE;
345             }
346              
347             # comment line (C)
348 91 100       418 if ($line =~ /^([;#%|*])(.*?)(\R?)\z/) {
349 35         130 push @$res, ['C', $1, $2, $3];
350 35         67 next LINE;
351             }
352              
353             # transaction comment (TC)
354 56 100 100     239 if ($in_tx && $line =~ /^(\s+);(.*?)(\R?)\z/) {
355 5         20 push @$res, ['TC', $1, $2, $3];
356 5         10 next LINE;
357             }
358              
359             # posting (P)
360 51 100 66     213 if ($in_tx && $line =~ /^\s/) {
361 50 100       1540 $line =~ m!^(\s+) # 1) ws1
362             (\[|\()? # 2) oparen
363             ($re_account) # 3) account
364             (\]|\))? # 4) cparen
365             (?: (\s{2,})($re_amount) )? # 5) ws2 6) amount
366             (?: (\s*) ;(.*?))? # 7) ws3 8) comment
367             (\R?)\z # 9) nl
368             !x
369             or $self->_err("Invalid posting line syntax");
370             # brace must match
371 49   100     350 my ($oparen, $cparen) = ($2 // '', $4 // '');
      100        
372 49 100 66     219 unless (!$oparen && !$cparen ||
      100        
      100        
      100        
      100        
373             $oparen eq '[' && $cparen eq ']' ||
374             $oparen eq '(' && $cparen eq ')') {
375 2         5 $self->_err("Parentheses/braces around account don't match");
376             }
377 47         235 my $parsed_line = ['P', $1, $oparen, $3, $cparen,
378             $5, $6, $7, $8, $9];
379 47 100       129 if (defined $6) {
380 32         75 my $parse_amount = $self->_parse_amount($6);
381 32 100       5439 if ($parse_amount->[0] != 200) {
382 1         4 $self->_err($parse_amount->[1]);
383             }
384 31         81 $parsed_line->[COL_P_PARSE_AMOUNT] = $parse_amount->[2];
385             }
386 46         81 push @$res, $parsed_line;
387 46         117 next LINE;
388             }
389              
390 1         4 $self->_err("Invalid syntax");
391              
392             }
393              
394 12 100       30 if ($in_tx) {
395 3         8 my $parse_tx = $self->_parse_tx($res, $in_tx);
396 3 50       10 if ($parse_tx->[0] != 200) {
397 0         0 $self->_err($parse_tx->[1]);
398             }
399 3         8 $res->[$in_tx - 1][COL_T_PARSE_TX] = $parse_tx->[2];
400             }
401              
402 12         642 require Ledger::Journal;
403 12         57 Ledger::Journal->new(_parser=>$self, _parsed=>$res);
404             }
405              
406             sub _parsed_as_string {
407 1     1   31 no warnings 'uninitialized';
  1         3  
  1         299  
408              
409 12     12   24 my ($self, $parsed) = @_;
410              
411 12         20 my @res;
412 12         19 my $linum = 0;
413 12         26 for my $line (@$parsed) {
414 115         154 $linum++;
415 115         165 my $type = $line->[COL_TYPE];
416 115 100       279 if ($type eq 'B') {
    100          
    100          
    100          
    50          
417 26         52 push @res, $line->[COL_B_RAW];
418             } elsif ($type eq 'T') {
419 17 100       90 push @res, join(
    50          
    50          
420             "",
421             $line->[COL_T_DATE],
422             (length($line->[COL_T_EDATE]) ? "=".$line->[COL_T_EDATE] : ""),
423             $line->[COL_T_WS1], $line->[COL_T_STATE],
424             (length($line->[COL_T_CODE]) ? $line->[COL_T_WS2]."(".$line->[COL_T_CODE].")" : ""),
425             $line->[COL_T_WS3], $line->[COL_T_DESC],
426             (length($line->[COL_T_COMMENT]) ? $line->[COL_T_WS4]."(".$line->[COL_T_COMMENT].")" : ""),
427             $line->[COL_T_NL],
428             );
429             } elsif ($type eq 'C') {
430 30         43 push @res, join("", @{$line}[COL_C_CHAR .. COL_C_NL]);
  30         77  
431             } elsif ($type eq 'TC') {
432 5         14 push @res, join(
433             "",
434             $line->[COL_TC_WS1], ";",
435             $line->[COL_TC_COMMENT],
436             $line->[COL_TC_NL],
437             );
438             } elsif ($type eq 'P') {
439 37 100       148 push @res, join(
    100          
440             "",
441             $line->[COL_P_WS1],
442             $line->[COL_P_OPAREN],
443             $line->[COL_P_ACCOUNT],
444             $line->[COL_P_CPAREN],
445             (length($line->[COL_P_AMOUNT]) ? $line->[COL_P_WS2].$line->[COL_P_AMOUNT] : ""),
446             (length($line->[COL_P_COMMENT]) ? $line->[COL_P_WS3].";".$line->[COL_P_COMMENT] : ""),
447             $line->[COL_P_NL],
448             );
449             } else {
450 0         0 croak "Bad parsed data (line #$linum): unknown type '$type'";
451             }
452             }
453 12         86 join("", @res);
454             }
455              
456             1;
457             # ABSTRACT: Parse Ledger journals
458              
459             __END__