File Coverage

blib/lib/Ledger/Parser.pm
Criterion Covered Total %
statement 187 201 93.0
branch 96 116 82.7
condition 40 55 72.7
subroutine 21 22 95.4
pod 3 3 100.0
total 347 397 87.4


line stmt bran cond sub pod time code
1             package Ledger::Parser;
2              
3             our $DATE = '2016-01-12'; # DATE
4             our $VERSION = '0.05'; # VERSION
5              
6 1     1   1184519 use 5.010001;
  1         4  
7 1     1   5 use strict;
  1         2  
  1         20  
8 1     1   4 use utf8;
  1         1  
  1         7  
9 1     1   21 use warnings;
  1         3  
  1         23  
10 1     1   10 use Carp;
  1         1  
  1         57  
11              
12 1     1   1472 use Math::BigFloat;
  1         21997  
  1         6  
13 1     1   41667 use Time::Moment;
  1         1492  
  1         79  
14              
15             use constant +{
16 1         3115 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   7 };
  1         1  
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 36092 my ($class, %attrs) = @_;
77              
78 1   50     24 $attrs{input_date_format} //= 'YYYY/MM/DD';
79 1   33     115 $attrs{year} //= (localtime)[5] + 1900;
80             #$attrs{strict} //= 0; # check valid account names
81              
82             # checking
83 1 50       24 $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         10 bless \%attrs, $class;
87             }
88              
89             sub _parse_date {
90 26     26   66 my ($self, $str) = @_;
91 26 50       200 return [400,"Invalid date syntax '$str'"] unless $str =~ /\A(?:$RE_date)\z/;
92              
93 26         36 my $tm;
94 26         72 eval {
95 26 50       80 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     356 );
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       69 if ($@) { return [400, "Invalid date '$str': $@"] }
  1         5  
110 25         92 [200, "OK", $tm];
111             }
112              
113             sub _parse_amount {
114 32     32   71 my ($self, $str) = @_;
115 32 50       345 return [400, "Invalid amount syntax '$str'"]
116             unless $str =~ /\A(?:$RE_amount)\z/;
117              
118 32         126 my ($minsign, $commodity1, $ws1, $num, $ws2, $commodity2) =
119             ($1, $2, $3, $4, $5, $6);
120 32 100 100     123 if ($commodity1 && $commodity2) {
121 1         6 return [400, "Invalid amount '$str' (double commodity)"];
122             }
123 31         63 $num =~ s/,//g;
124 31 100       96 $num *= -1 if $minsign;
125 31 100 100     160 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   43 my ($self, $parsed, $linum0) = @_;
139              
140 20         39 my $t_line = $parsed->[$linum0-1];
141 20         119 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       61 $tx->{edate} = $t_line->[COL_T_PARSE_EDATE] if $t_line->[COL_T_EDATE];
148              
149 20         33 my $linum = $linum0;
150 20         27 while (1) {
151 71 100       206 last if $linum++ > @$parsed-1;
152 51         86 my $line = $parsed->[$linum-1];
153 51         97 my $type = $line->[COL_TYPE];
154 51 100       122 if ($type eq 'P') {
    50          
155 46   50     116 my $oparen = $line->[COL_P_OPAREN] // '';
156 46 100       73 push @{ $tx->{postings} }, {
  46 100       427  
    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       14 if (@{ $tx->{postings} }) {
  5         20  
171 1         3 push @{ $tx->{postings}[-1]{_ledger_raw_comments} }, $line;
  1         7  
172             } else {
173 4         9 push @{ $tx->{_ledger_raw_comments} }, $line;
  4         19  
174             }
175             } else {
176 0         0 last;
177             }
178             }
179              
180             # some sanity checks for the transaction
181             CHECK:
182             {
183 20         29 my $num_postings = @{$tx->{postings}};
  20         26  
  20         49  
184 20 100       50 last CHECK if !$num_postings;
185 19 50 66     64 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         31 my $num_nulls = 0;
191 19         28 my %bals; # key = commodity
192 19         26 for my $p (@{ $tx->{postings} }) {
  19         48  
193 46 100       4371 if (!defined($p->{amount})) {
194 15         20 $num_nulls++;
195 15         34 next;
196             }
197 31         146 $bals{$p->{commodity}} += $p->{amount};
198             }
199 19 100       937 last CHECK if $num_nulls == 1;
200 6 100       22 if ($num_nulls) {
201 1         4 $self->_err("There can only be one posting with null amount");
202             }
203 5         16 for (keys %bals) {
204             $self->_err("Transaction not balanced, " .
205             (-$bals{$_}) . ($_ ? " $_":"")." needed")
206 6 100       182 if $bals{$_} != 0;
    100          
207             }
208             }
209              
210 17         688 [200, "OK", $tx];
211             }
212              
213             sub _err {
214 9     9   485 my ($self, $msg) = @_;
215             croak join(
216             "",
217 9 50       15 @{ $self->{_include_stack} } ? "$self->{_include_stack}[0] " : "",
  9         150  
218             "line $self->{_linum}: ",
219             $msg
220             );
221             }
222              
223             sub _push_include_stack {
224 21     21   132 require Cwd;
225              
226 21         40 my ($self, $path) = @_;
227              
228             # included file's path is based on the main (topmost) file
229 21 50       26 if (@{ $self->{_include_stack} }) {
  21         72  
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       1216 my $abs_path = Cwd::abs_path($path) or return [400, "Invalid path name"];
237             return [409, "Recursive", $abs_path]
238 21 50       28 if grep { $_ eq $abs_path } @{ $self->{_include_stack} };
  0         0  
  21         71  
239 21         34 push @{ $self->{_include_stack} }, $abs_path;
  21         51  
240 21         65 return [200, "OK", $abs_path];
241             }
242              
243             sub _pop_include_stack {
244 12     12   17 my $self = shift;
245              
246 12 50       17 die "BUG: Overpopped _pop_include_stack" unless @{$self->{_include_stack}};
  12         36  
247 12         21 pop @{ $self->{_include_stack} };
  12         25  
248             }
249              
250             sub _init_read {
251 21     21   33 my $self = shift;
252              
253 21         66 $self->{_include_stack} = [];
254             }
255              
256             sub _read_file {
257 21     21   35 my ($self, $filename) = @_;
258 21 50       708 open my $fh, "<", $filename
259             or die "Can't open file '$filename': $!";
260 21         84 binmode($fh, ":utf8");
261 21         69 local $/;
262 21         729 return ~~<$fh>;
263             }
264              
265             sub read_file {
266 21     21 1 50889 my ($self, $filename) = @_;
267 21         61 $self->_init_read;
268 21         65 my $res = $self->_push_include_stack($filename);
269 21 50       106 die "Can't read '$filename': $res->[1]" unless $res->[0] == 200;
270 21         101 $res =
271             $self->_read_string($self->_read_file($filename));
272 12         47 $self->_pop_include_stack;
273 12         67 $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         36 my $res = [];
286              
287 21         37 my $in_tx;
288              
289 21         103 my @lines = split /^/, $str;
290 21         56 local $self->{_linum} = 0;
291             LINE:
292 21         43 for my $line (@lines) {
293 146         287 $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 146 100 66     786 if ($in_tx && ($line !~ /\S/ || $line =~ /^\S/)) {
      66        
298 17         58 my $parse_tx = $self->_parse_tx($res, $in_tx);
299 14 50       45 if ($parse_tx->[0] != 200) {
300 0         0 $self->_err($parse_tx->[1]);
301             }
302 14         35 $res->[$in_tx - 1][COL_T_PARSE_TX] = $parse_tx->[2];
303 14         37 $in_tx = 0;
304             }
305              
306             # blank line (B)
307 143 100       485 if ($line !~ /\S/) {
308 29         86 push @$res, [
309             'B',
310             $line, # COL_B_RAW
311             ];
312 29         63 next LINE;
313             }
314              
315             # transaction line (T)
316 114 100       332 if ($line =~ /^\d/) {
317 25 50       596 $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         202 my $parsed_line = ['T', $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11];
327              
328 25         87 my $parse_date = $self->_parse_date($1);
329 25 100       75 if ($parse_date->[0] != 200) {
330 1         5 $self->_err($parse_date->[1]);
331             }
332 24         60 $parsed_line->[COL_T_PARSE_DATE] = $parse_date->[2];
333              
334 24 100       71 if ($2) {
335 1         6 my $parse_edate = $self->_parse_date($2);
336 1 50       7 if ($parse_edate->[0] != 200) {
337 0         0 $self->_err($parse_edate->[1]);
338             }
339 1         5 $parsed_line->[COL_T_PARSE_EDATE] = $parse_edate->[2];
340             }
341              
342 24         49 $in_tx = $self->{_linum};
343 24         51 push @$res, $parsed_line;
344 24         81 next LINE;
345             }
346              
347             # comment line (C)
348 89 100       598 if ($line =~ /^([;#%|*])(.*?)(\R?)\z/) {
349 33         173 push @$res, ['C', $1, $2, $3];
350 33         75 next LINE;
351             }
352              
353             # transaction comment (TC)
354 56 100 100     351 if ($in_tx && $line =~ /^(\s+);(.*?)(\R?)\z/) {
355 5         36 push @$res, ['TC', $1, $2, $3];
356 5         16 next LINE;
357             }
358              
359             # posting (P)
360 51 100 66     278 if ($in_tx && $line =~ /^\s/) {
361 50 100       2511 $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     396 my ($oparen, $cparen) = ($2 // '', $4 // '');
      100        
372 49 100 66     274 unless (!$oparen && !$cparen ||
      100        
      66        
      100        
      66        
373             $oparen eq '[' && $cparen eq ']' ||
374             $oparen eq '(' && $cparen eq ')') {
375 2         7 $self->_err("Parentheses/braces around account don't match");
376             }
377 47         311 my $parsed_line = ['P', $1, $oparen, $3, $cparen,
378             $5, $6, $7, $8, $9];
379 47 100       148 if (defined $6) {
380 32         96 my $parse_amount = $self->_parse_amount($6);
381 32 100       5744 if ($parse_amount->[0] != 200) {
382 1         5 $self->_err($parse_amount->[1]);
383             }
384 31         103 $parsed_line->[COL_P_PARSE_AMOUNT] = $parse_amount->[2];
385             }
386 46         89 push @$res, $parsed_line;
387 46         154 next LINE;
388             }
389              
390 1         4 $self->_err("Invalid syntax");
391              
392             }
393              
394 12 100       34 if ($in_tx) {
395 3         11 my $parse_tx = $self->_parse_tx($res, $in_tx);
396 3 50       11 if ($parse_tx->[0] != 200) {
397 0         0 $self->_err($parse_tx->[1]);
398             }
399 3         9 $res->[$in_tx - 1][COL_T_PARSE_TX] = $parse_tx->[2];
400             }
401              
402 12         882 require Ledger::Journal;
403 12         67 Ledger::Journal->new(_parser=>$self, _parsed=>$res);
404             }
405              
406             sub _parsed_as_string {
407 1     1   6 no warnings 'uninitialized';
  1         2  
  1         468  
408              
409 12     12   20 my ($self, $parsed) = @_;
410              
411 12         19 my @res;
412 12         21 my $linum = 0;
413 12         30 for my $line (@$parsed) {
414 113         127 $linum++;
415 113         225 my $type = $line->[COL_TYPE];
416 113 100       357 if ($type eq 'B') {
    100          
    100          
    100          
    50          
417 26         64 push @res, $line->[COL_B_RAW];
418             } elsif ($type eq 'T') {
419 17 100       105 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 28         42 push @res, join("", @{$line}[COL_C_CHAR .. COL_C_NL]);
  28         98  
431             } elsif ($type eq 'TC') {
432 5         16 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       196 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 die "Bad parsed data (line #$linum): unknown type '$type'";
451             }
452             }
453 12         95 join("", @res);
454             }
455              
456             1;
457             # ABSTRACT: Parse Ledger journals
458              
459             __END__