852 lines
35 KiB
Haskell
852 lines
35 KiB
Haskell
{-|
|
||
|
||
A reader for hledger's (and c++ ledger's) journal file format.
|
||
|
||
From the ledger 2.5 manual:
|
||
|
||
@
|
||
The ledger file format is quite simple, but also very flexible. It supports
|
||
many options, though typically the user can ignore most of them. They are
|
||
summarized below. The initial character of each line determines what the
|
||
line means, and how it should be interpreted. Allowable initial characters
|
||
are:
|
||
|
||
NUMBER A line beginning with a number denotes an entry. It may be followed by any
|
||
number of lines, each beginning with whitespace, to denote the entry’s account
|
||
transactions. The format of the first line is:
|
||
|
||
DATE[=EDATE] [*|!] [(CODE)] DESC
|
||
|
||
If ‘*’ appears after the date (with optional effective date), it indicates the entry
|
||
is “cleared”, which can mean whatever the user wants it t omean. If ‘!’ appears
|
||
after the date, it indicates d the entry is “pending”; i.e., tentatively cleared from
|
||
the user’s point of view, but not yet actually cleared. If a ‘CODE’ appears in
|
||
parentheses, it may be used to indicate a check number, or the type of the
|
||
transaction. Following these is the payee, or a description of the transaction.
|
||
The format of each following transaction is:
|
||
|
||
ACCOUNT AMOUNT [; NOTE]
|
||
|
||
The ‘ACCOUNT’ may be surrounded by parentheses if it is a virtual
|
||
transactions, or square brackets if it is a virtual transactions that must
|
||
balance. The ‘AMOUNT’ can be followed by a per-unit transaction cost,
|
||
by specifying ‘ AMOUNT’, or a complete transaction cost with ‘\@ AMOUNT’.
|
||
Lastly, the ‘NOTE’ may specify an actual and/or effective date for the
|
||
transaction by using the syntax ‘[ACTUAL_DATE]’ or ‘[=EFFECTIVE_DATE]’ or
|
||
‘[ACTUAL_DATE=EFFECtIVE_DATE]’.
|
||
|
||
= An automated entry. A value expression must appear after the equal sign.
|
||
After this initial line there should be a set of one or more transactions, just as
|
||
if it were normal entry. If the amounts of the transactions have no commodity,
|
||
they will be applied as modifiers to whichever real transaction is matched by
|
||
the value expression.
|
||
|
||
~ A period entry. A period expression must appear after the tilde.
|
||
After this initial line there should be a set of one or more transactions, just as
|
||
if it were normal entry.
|
||
|
||
! A line beginning with an exclamation mark denotes a command directive. It
|
||
must be immediately followed by the command word. The supported commands
|
||
are:
|
||
|
||
‘!include’
|
||
Include the stated ledger file.
|
||
‘!account’
|
||
The account name is given is taken to be the parent of all transac-
|
||
tions that follow, until ‘!end’ is seen.
|
||
‘!end’ Ends an account block.
|
||
|
||
; A line beginning with a colon indicates a comment, and is ignored.
|
||
|
||
Y If a line begins with a capital Y, it denotes the year used for all subsequent
|
||
entries that give a date without a year. The year should appear immediately
|
||
after the Y, for example: ‘Y2004’. This is useful at the beginning of a file, to
|
||
specify the year for that file. If all entries specify a year, however, this command
|
||
has no effect.
|
||
|
||
|
||
P Specifies a historical price for a commodity. These are usually found in a pricing
|
||
history file (see the ‘-Q’ option). The syntax is:
|
||
|
||
P DATE SYMBOL PRICE
|
||
|
||
N SYMBOL Indicates that pricing information is to be ignored for a given symbol, nor will
|
||
quotes ever be downloaded for that symbol. Useful with a home currency, such
|
||
as the dollar ($). It is recommended that these pricing options be set in the price
|
||
database file, which defaults to ‘~/.pricedb’. The syntax for this command is:
|
||
|
||
N SYMBOL
|
||
|
||
|
||
D AMOUNT Specifies the default commodity to use, by specifying an amount in the expected
|
||
format. The entry command will use this commodity as the default when none
|
||
other can be determined. This command may be used multiple times, to set
|
||
the default flags for different commodities; whichever is seen last is used as the
|
||
default commodity. For example, to set US dollars as the default commodity,
|
||
while also setting the thousands flag and decimal flag for that commodity, use:
|
||
|
||
D $1,000.00
|
||
|
||
C AMOUNT1 = AMOUNT2
|
||
Specifies a commodity conversion, where the first amount is given to be equiv-
|
||
alent to the second amount. The first amount should use the decimal precision
|
||
desired during reporting:
|
||
|
||
C 1.00 Kb = 1024 bytes
|
||
|
||
i, o, b, h
|
||
These four relate to timeclock support, which permits ledger to read timelog
|
||
files. See the timeclock’s documentation for more info on the syntax of its
|
||
timelog files.
|
||
@
|
||
|
||
-}
|
||
|
||
module Hledger.Read.JournalReader (
|
||
emptyLine,
|
||
journalAddFile,
|
||
journalFile,
|
||
ledgeraccountname,
|
||
ledgerdatetime,
|
||
ledgerDefaultYear,
|
||
ledgerDirective,
|
||
ledgerHistoricalPrice,
|
||
reader,
|
||
someamount,
|
||
tests_Hledger_Read_JournalReader
|
||
)
|
||
where
|
||
import Control.Monad
|
||
import Control.Monad.Error
|
||
import Data.Char (isNumber)
|
||
import Data.List
|
||
import Data.List.Split (wordsBy)
|
||
import Data.Maybe
|
||
import Data.Time.Calendar
|
||
-- import Data.Time.Clock
|
||
-- import Data.Time.Format
|
||
import Data.Time.LocalTime
|
||
import Safe (headDef)
|
||
-- import System.Locale (defaultTimeLocale)
|
||
import Test.HUnit
|
||
import Text.ParserCombinators.Parsec hiding (parse)
|
||
import Text.Printf
|
||
|
||
import Hledger.Data
|
||
import Hledger.Read.Utils
|
||
import Hledger.Utils
|
||
import Prelude hiding (readFile)
|
||
import Hledger.Utils.UTF8 (readFile)
|
||
|
||
|
||
-- let's get to it
|
||
|
||
reader :: Reader
|
||
reader = Reader format detect parse
|
||
|
||
format :: String
|
||
format = "journal"
|
||
|
||
-- | Does the given file path and data provide hledger's journal file format ?
|
||
detect :: FilePath -> String -> Bool
|
||
detect f _ = fileSuffix f == format
|
||
|
||
-- | Parse and post-process a "Journal" from hledger's journal file
|
||
-- format, or give an error.
|
||
parse :: Maybe ParseRules -> FilePath -> String -> ErrorT String IO Journal
|
||
parse _ = parseJournalWith journalFile
|
||
|
||
-- | Top-level journal parser. Returns a single composite, I/O performing,
|
||
-- error-raising "JournalUpdate" (and final "JournalContext") which can be
|
||
-- applied to an empty journal to get the final result.
|
||
journalFile :: GenParser Char JournalContext (JournalUpdate,JournalContext)
|
||
journalFile = do
|
||
journalupdates <- many journalItem
|
||
eof
|
||
finalctx <- getState
|
||
return $ (juSequence journalupdates, finalctx)
|
||
where
|
||
-- As all journal line types can be distinguished by the first
|
||
-- character, excepting transactions versus empty (blank or
|
||
-- comment-only) lines, can use choice w/o try
|
||
journalItem = choice [ ledgerDirective
|
||
, liftM (return . addTransaction) ledgerTransaction
|
||
, liftM (return . addModifierTransaction) ledgerModifierTransaction
|
||
, liftM (return . addPeriodicTransaction) ledgerPeriodicTransaction
|
||
, liftM (return . addHistoricalPrice) ledgerHistoricalPrice
|
||
, emptyLine >> return (return id)
|
||
] <?> "journal transaction or directive"
|
||
|
||
emptyLine :: GenParser Char JournalContext ()
|
||
emptyLine = do many spacenonewline
|
||
optional $ (char ';' <?> "comment") >> many (noneOf "\n")
|
||
newline
|
||
return ()
|
||
|
||
ledgercomment :: GenParser Char JournalContext String
|
||
ledgercomment = do
|
||
many1 $ char ';'
|
||
many spacenonewline
|
||
many (noneOf "\n")
|
||
<?> "comment"
|
||
|
||
ledgercommentline :: GenParser Char JournalContext String
|
||
ledgercommentline = do
|
||
many spacenonewline
|
||
s <- ledgercomment
|
||
optional newline
|
||
eof
|
||
return s
|
||
<?> "comment"
|
||
|
||
ledgerDirective :: GenParser Char JournalContext JournalUpdate
|
||
ledgerDirective = do
|
||
optional $ char '!'
|
||
choice' [
|
||
ledgerInclude
|
||
,ledgerAlias
|
||
,ledgerEndAliases
|
||
,ledgerAccountBegin
|
||
,ledgerAccountEnd
|
||
,ledgerTagDirective
|
||
,ledgerEndTagDirective
|
||
,ledgerDefaultYear
|
||
,ledgerDefaultCommodity
|
||
,ledgerCommodityConversion
|
||
,ledgerIgnoredPriceCommodity
|
||
]
|
||
<?> "directive"
|
||
|
||
ledgerInclude :: GenParser Char JournalContext JournalUpdate
|
||
ledgerInclude = do
|
||
string "include"
|
||
many1 spacenonewline
|
||
filename <- restofline
|
||
outerState <- getState
|
||
outerPos <- getPosition
|
||
return $ do filepath <- expandPath outerPos filename
|
||
txt <- readFileOrError outerPos filepath
|
||
let inIncluded = show outerPos ++ " in included file " ++ show filename ++ ":\n"
|
||
case runParser journalFile outerState filepath txt of
|
||
Right (ju,_) -> juSequence [return $ journalAddFile (filepath,txt), ju] `catchError` (throwError . (inIncluded ++))
|
||
Left err -> throwError $ inIncluded ++ show err
|
||
where readFileOrError pos fp =
|
||
ErrorT $ liftM Right (readFile fp) `catch`
|
||
\err -> return $ Left $ printf "%s reading %s:\n%s" (show pos) fp (show err)
|
||
|
||
journalAddFile :: (FilePath,String) -> Journal -> Journal
|
||
journalAddFile f j@Journal{files=fs} = j{files=fs++[f]}
|
||
|
||
ledgerAccountBegin :: GenParser Char JournalContext JournalUpdate
|
||
ledgerAccountBegin = do
|
||
string "account"
|
||
many1 spacenonewline
|
||
parent <- ledgeraccountname
|
||
newline
|
||
pushParentAccount parent
|
||
return $ return id
|
||
|
||
ledgerAccountEnd :: GenParser Char JournalContext JournalUpdate
|
||
ledgerAccountEnd = do
|
||
string "end"
|
||
popParentAccount
|
||
return (return id)
|
||
|
||
ledgerAlias :: GenParser Char JournalContext JournalUpdate
|
||
ledgerAlias = do
|
||
string "alias"
|
||
many1 spacenonewline
|
||
orig <- many1 $ noneOf "="
|
||
char '='
|
||
alias <- restofline
|
||
addAccountAlias (accountNameWithoutPostingType $ strip orig
|
||
,accountNameWithoutPostingType $ strip alias)
|
||
return $ return id
|
||
|
||
ledgerEndAliases :: GenParser Char JournalContext JournalUpdate
|
||
ledgerEndAliases = do
|
||
string "end aliases"
|
||
clearAccountAliases
|
||
return (return id)
|
||
|
||
ledgerTagDirective :: GenParser Char JournalContext JournalUpdate
|
||
ledgerTagDirective = do
|
||
string "tag" <?> "tag directive"
|
||
many1 spacenonewline
|
||
_ <- many1 nonspace
|
||
restofline
|
||
return $ return id
|
||
|
||
ledgerEndTagDirective :: GenParser Char JournalContext JournalUpdate
|
||
ledgerEndTagDirective = do
|
||
(string "end tag" <|> string "pop") <?> "end tag or pop directive"
|
||
restofline
|
||
return $ return id
|
||
|
||
ledgerDefaultYear :: GenParser Char JournalContext JournalUpdate
|
||
ledgerDefaultYear = do
|
||
char 'Y' <?> "default year"
|
||
many spacenonewline
|
||
y <- many1 digit
|
||
let y' = read y
|
||
failIfInvalidYear y
|
||
setYear y'
|
||
return $ return id
|
||
|
||
ledgerDefaultCommodity :: GenParser Char JournalContext JournalUpdate
|
||
ledgerDefaultCommodity = do
|
||
char 'D' <?> "default commodity"
|
||
many1 spacenonewline
|
||
a <- someamount
|
||
-- someamount always returns a MixedAmount containing one Amount, but let's be safe
|
||
let as = amounts a
|
||
when (not $ null as) $ setCommodity $ commodity $ head as
|
||
restofline
|
||
return $ return id
|
||
|
||
ledgerHistoricalPrice :: GenParser Char JournalContext HistoricalPrice
|
||
ledgerHistoricalPrice = do
|
||
char 'P' <?> "historical price"
|
||
many spacenonewline
|
||
date <- try (do {LocalTime d _ <- ledgerdatetime; return d}) <|> ledgerdate -- a time is ignored
|
||
many1 spacenonewline
|
||
symbol <- commoditysymbol
|
||
many spacenonewline
|
||
price <- someamount
|
||
restofline
|
||
return $ HistoricalPrice date symbol price
|
||
|
||
ledgerIgnoredPriceCommodity :: GenParser Char JournalContext JournalUpdate
|
||
ledgerIgnoredPriceCommodity = do
|
||
char 'N' <?> "ignored-price commodity"
|
||
many1 spacenonewline
|
||
commoditysymbol
|
||
restofline
|
||
return $ return id
|
||
|
||
ledgerCommodityConversion :: GenParser Char JournalContext JournalUpdate
|
||
ledgerCommodityConversion = do
|
||
char 'C' <?> "commodity conversion"
|
||
many1 spacenonewline
|
||
someamount
|
||
many spacenonewline
|
||
char '='
|
||
many spacenonewline
|
||
someamount
|
||
restofline
|
||
return $ return id
|
||
|
||
ledgerModifierTransaction :: GenParser Char JournalContext ModifierTransaction
|
||
ledgerModifierTransaction = do
|
||
char '=' <?> "modifier transaction"
|
||
many spacenonewline
|
||
valueexpr <- restofline
|
||
postings <- ledgerpostings
|
||
return $ ModifierTransaction valueexpr postings
|
||
|
||
ledgerPeriodicTransaction :: GenParser Char JournalContext PeriodicTransaction
|
||
ledgerPeriodicTransaction = do
|
||
char '~' <?> "periodic transaction"
|
||
many spacenonewline
|
||
periodexpr <- restofline
|
||
postings <- ledgerpostings
|
||
return $ PeriodicTransaction periodexpr postings
|
||
|
||
-- | Parse a (possibly unbalanced) ledger transaction.
|
||
ledgerTransaction :: GenParser Char JournalContext Transaction
|
||
ledgerTransaction = do
|
||
date <- ledgerdate <?> "transaction"
|
||
edate <- optionMaybe (ledgereffectivedate date) <?> "effective date"
|
||
status <- ledgerstatus <?> "cleared flag"
|
||
code <- ledgercode <?> "transaction code"
|
||
(description, comment) <-
|
||
(do {many1 spacenonewline; d <- liftM rstrip (many (noneOf ";\n")); c <- ledgercomment <|> return ""; newline; return (d, c)} <|>
|
||
do {many spacenonewline; c <- ledgercomment <|> return ""; newline; return ("", c)}
|
||
) <?> "description and/or comment"
|
||
md <- try ledgermetadata <|> return []
|
||
postings <- ledgerpostings
|
||
return $ txnTieKnot $ Transaction date edate status code description comment md postings ""
|
||
|
||
-- | Parse a date in YYYY/MM/DD format. Fewer digits are allowed. The year
|
||
-- may be omitted if a default year has already been set.
|
||
ledgerdate :: GenParser Char JournalContext Day
|
||
ledgerdate = do
|
||
-- hacky: try to ensure precise errors for invalid dates
|
||
-- XXX reported error position is not too good
|
||
-- pos <- getPosition
|
||
datestr <- many1 $ choice' [digit, datesepchar]
|
||
let dateparts = wordsBy (`elem` datesepchars) datestr
|
||
currentyear <- getYear
|
||
[y,m,d] <- case (dateparts,currentyear) of
|
||
([m,d],Just y) -> return [show y,m,d]
|
||
([_,_],Nothing) -> fail $ "partial date "++datestr++" found, but the current year is unknown"
|
||
([y,m,d],_) -> return [y,m,d]
|
||
_ -> fail $ "bad date: " ++ datestr
|
||
let maybedate = fromGregorianValid (read y) (read m) (read d)
|
||
case maybedate of
|
||
Nothing -> fail $ "bad date: " ++ datestr
|
||
Just date -> return date
|
||
<?> "full or partial date"
|
||
|
||
-- | Parse a date and time in YYYY/MM/DD HH:MM[:SS][+-ZZZZ] format. Any
|
||
-- timezone will be ignored; the time is treated as local time. Fewer
|
||
-- digits are allowed, except in the timezone. The year may be omitted if
|
||
-- a default year has already been set.
|
||
ledgerdatetime :: GenParser Char JournalContext LocalTime
|
||
ledgerdatetime = do
|
||
day <- ledgerdate
|
||
many1 spacenonewline
|
||
h <- many1 digit
|
||
let h' = read h
|
||
guard $ h' >= 0 && h' <= 23
|
||
char ':'
|
||
m <- many1 digit
|
||
let m' = read m
|
||
guard $ m' >= 0 && m' <= 59
|
||
s <- optionMaybe $ char ':' >> many1 digit
|
||
let s' = case s of Just sstr -> read sstr
|
||
Nothing -> 0
|
||
guard $ s' >= 0 && s' <= 59
|
||
{- tz <- -}
|
||
optionMaybe $ do
|
||
plusminus <- oneOf "-+"
|
||
d1 <- digit
|
||
d2 <- digit
|
||
d3 <- digit
|
||
d4 <- digit
|
||
return $ plusminus:d1:d2:d3:d4:""
|
||
-- ltz <- liftIO $ getCurrentTimeZone
|
||
-- let tz' = maybe ltz (fromMaybe ltz . parseTime defaultTimeLocale "%z") tz
|
||
-- return $ localTimeToUTC tz' $ LocalTime day $ TimeOfDay h' m' (fromIntegral s')
|
||
return $ LocalTime day $ TimeOfDay h' m' (fromIntegral s')
|
||
|
||
ledgereffectivedate :: Day -> GenParser Char JournalContext Day
|
||
ledgereffectivedate actualdate = do
|
||
char '='
|
||
-- kludgy way to use actual date for default year
|
||
let withDefaultYear d p = do
|
||
y <- getYear
|
||
let (y',_,_) = toGregorian d in setYear y'
|
||
r <- p
|
||
when (isJust y) $ setYear $ fromJust y
|
||
return r
|
||
edate <- withDefaultYear actualdate ledgerdate
|
||
return edate
|
||
|
||
ledgerstatus :: GenParser Char JournalContext Bool
|
||
ledgerstatus = try (do { many spacenonewline; char '*' <?> "status"; return True } ) <|> return False
|
||
|
||
ledgercode :: GenParser Char JournalContext String
|
||
ledgercode = try (do { many1 spacenonewline; char '(' <?> "code"; code <- anyChar `manyTill` char ')'; return code } ) <|> return ""
|
||
|
||
ledgermetadata :: GenParser Char JournalContext [(String,String)]
|
||
ledgermetadata = many ledgermetadataline
|
||
|
||
-- a comment line containing a metadata declaration, eg:
|
||
-- ; name: value
|
||
ledgermetadataline :: GenParser Char JournalContext (String,String)
|
||
ledgermetadataline = do
|
||
many1 spacenonewline
|
||
many1 $ char ';'
|
||
many spacenonewline
|
||
name <- many1 $ noneOf ": \t"
|
||
char ':'
|
||
many spacenonewline
|
||
value <- many (noneOf "\n")
|
||
optional newline
|
||
-- eof
|
||
return (name,value)
|
||
<?> "metadata line"
|
||
|
||
-- Parse the following whitespace-beginning lines as postings, posting metadata, and/or comments.
|
||
-- complicated to handle intermixed comment and metadata lines.. make me better ?
|
||
ledgerpostings :: GenParser Char JournalContext [Posting]
|
||
ledgerpostings = do
|
||
ctx <- getState
|
||
-- we'll set the correct position for sub-parses for more useful errors
|
||
pos <- getPosition
|
||
ls <- many1 $ try linebeginningwithspaces
|
||
let lsnumbered = zip ls [0..]
|
||
parses p = isRight . parseWithCtx ctx p
|
||
postinglines = filter (not . (ledgercommentline `parses`) . fst) lsnumbered
|
||
-- group any metadata lines with the posting line above
|
||
postinglinegroups :: [(String,Line)] -> [(String,Line)]
|
||
postinglinegroups [] = []
|
||
postinglinegroups ((pline,num):ls) = (unlines (pline:(map fst mdlines)), num):postinglinegroups rest
|
||
where (mdlines,rest) = span ((ledgermetadataline `parses`) . fst) ls
|
||
pstrs = postinglinegroups postinglines
|
||
parseNumberedPostingLine (str,num) = fromparse $ parseWithCtx ctx (setPosition (incSourceLine pos num) >> ledgerposting) str
|
||
when (null pstrs) $ fail "no postings"
|
||
return $ map parseNumberedPostingLine pstrs
|
||
<?> "postings"
|
||
|
||
linebeginningwithspaces :: GenParser Char JournalContext String
|
||
linebeginningwithspaces = do
|
||
sp <- many1 spacenonewline
|
||
c <- nonspace
|
||
cs <- restofline
|
||
return $ sp ++ (c:cs) ++ "\n"
|
||
|
||
ledgerposting :: GenParser Char JournalContext Posting
|
||
ledgerposting = do
|
||
many1 spacenonewline
|
||
status <- ledgerstatus
|
||
many spacenonewline
|
||
account <- modifiedaccountname
|
||
let (ptype, account') = (accountNamePostingType account, unbracket account)
|
||
amount <- postingamount
|
||
many spacenonewline
|
||
comment <- ledgercomment <|> return ""
|
||
newline
|
||
md <- ledgermetadata
|
||
return (Posting status account' amount comment ptype md Nothing)
|
||
|
||
-- Parse an account name, then apply any parent account prefix and/or account aliases currently in effect.
|
||
modifiedaccountname :: GenParser Char JournalContext AccountName
|
||
modifiedaccountname = do
|
||
a <- ledgeraccountname
|
||
prefix <- getParentAccount
|
||
let prefixed = prefix `joinAccountNames` a
|
||
aliases <- getAccountAliases
|
||
return $ accountNameApplyAliases aliases prefixed
|
||
|
||
-- | Parse an account name. Account names may have single spaces inside
|
||
-- them, and are terminated by two or more spaces. They should have one or
|
||
-- more components of at least one character, separated by the account
|
||
-- separator char.
|
||
ledgeraccountname :: GenParser Char st AccountName
|
||
ledgeraccountname = do
|
||
a <- many1 (nonspace <|> singlespace)
|
||
let a' = striptrailingspace a
|
||
when (accountNameFromComponents (accountNameComponents a') /= a')
|
||
(fail $ "accountname seems ill-formed: "++a')
|
||
return a'
|
||
where
|
||
singlespace = try (do {spacenonewline; do {notFollowedBy spacenonewline; return ' '}})
|
||
-- couldn't avoid consuming a final space sometimes, harmless
|
||
striptrailingspace s = if last s == ' ' then init s else s
|
||
|
||
-- accountnamechar = notFollowedBy (oneOf "()[]") >> nonspace
|
||
-- <?> "account name character (non-bracket, non-parenthesis, non-whitespace)"
|
||
|
||
-- | Parse an amount, with an optional left or right currency symbol and
|
||
-- optional price.
|
||
postingamount :: GenParser Char JournalContext MixedAmount
|
||
postingamount =
|
||
try (do
|
||
many1 spacenonewline
|
||
someamount <|> return missingamt
|
||
) <|> return missingamt
|
||
|
||
someamount :: GenParser Char JournalContext MixedAmount
|
||
someamount = try leftsymbolamount <|> try rightsymbolamount <|> nosymbolamount
|
||
|
||
leftsymbolamount :: GenParser Char JournalContext MixedAmount
|
||
leftsymbolamount = do
|
||
sign <- optionMaybe $ string "-"
|
||
let applysign = if isJust sign then negate else id
|
||
sym <- commoditysymbol
|
||
sp <- many spacenonewline
|
||
(q,p,d,s,spos) <- number
|
||
pri <- priceamount
|
||
let c = Commodity {symbol=sym,side=L,spaced=not $ null sp,decimalpoint=d,precision=p,separator=s,separatorpositions=spos}
|
||
return $ applysign $ Mixed [Amount c q pri]
|
||
<?> "left-symbol amount"
|
||
|
||
rightsymbolamount :: GenParser Char JournalContext MixedAmount
|
||
rightsymbolamount = do
|
||
(q,p,d,s,spos) <- number
|
||
sp <- many spacenonewline
|
||
sym <- commoditysymbol
|
||
pri <- priceamount
|
||
let c = Commodity {symbol=sym,side=R,spaced=not $ null sp,decimalpoint=d,precision=p,separator=s,separatorpositions=spos}
|
||
return $ Mixed [Amount c q pri]
|
||
<?> "right-symbol amount"
|
||
|
||
nosymbolamount :: GenParser Char JournalContext MixedAmount
|
||
nosymbolamount = do
|
||
(q,p,d,s,spos) <- number
|
||
pri <- priceamount
|
||
defc <- getCommodity
|
||
let c = fromMaybe Commodity{symbol="",side=L,spaced=False,decimalpoint=d,precision=p,separator=s,separatorpositions=spos} defc
|
||
return $ Mixed [Amount c q pri]
|
||
<?> "no-symbol amount"
|
||
|
||
commoditysymbol :: GenParser Char JournalContext String
|
||
commoditysymbol = (quotedcommoditysymbol <|> simplecommoditysymbol) <?> "commodity symbol"
|
||
|
||
quotedcommoditysymbol :: GenParser Char JournalContext String
|
||
quotedcommoditysymbol = do
|
||
char '"'
|
||
s <- many1 $ noneOf ";\n\""
|
||
char '"'
|
||
return s
|
||
|
||
simplecommoditysymbol :: GenParser Char JournalContext String
|
||
simplecommoditysymbol = many1 (noneOf nonsimplecommoditychars)
|
||
|
||
priceamount :: GenParser Char JournalContext (Maybe Price)
|
||
priceamount =
|
||
try (do
|
||
many spacenonewline
|
||
char '@'
|
||
try (do
|
||
char '@'
|
||
many spacenonewline
|
||
a <- someamount -- XXX can parse more prices ad infinitum, shouldn't
|
||
return $ Just $ TotalPrice a)
|
||
<|> (do
|
||
many spacenonewline
|
||
a <- someamount -- XXX can parse more prices ad infinitum, shouldn't
|
||
return $ Just $ UnitPrice a))
|
||
<|> return Nothing
|
||
|
||
-- gawd.. trying to parse a ledger number without error:
|
||
|
||
type Quantity = Double
|
||
|
||
-- -- | Parse a ledger-style numeric quantity and also return the number of
|
||
-- -- digits to the right of the decimal point and whether thousands are
|
||
-- -- separated by comma.
|
||
-- amountquantity :: GenParser Char JournalContext (Quantity, Int, Bool)
|
||
-- amountquantity = do
|
||
-- sign <- optionMaybe $ string "-"
|
||
-- (intwithcommas,frac) <- numberparts
|
||
-- let comma = ',' `elem` intwithcommas
|
||
-- let precision = length frac
|
||
-- -- read the actual value. We expect this read to never fail.
|
||
-- let int = filter (/= ',') intwithcommas
|
||
-- let int' = if null int then "0" else int
|
||
-- let frac' = if null frac then "0" else frac
|
||
-- let sign' = fromMaybe "" sign
|
||
-- let quantity = read $ sign'++int'++"."++frac'
|
||
-- return (quantity, precision, comma)
|
||
-- <?> "commodity quantity"
|
||
|
||
-- -- | parse the two strings of digits before and after a possible decimal
|
||
-- -- point. The integer part may contain commas, or either part may be
|
||
-- -- empty, or there may be no point.
|
||
-- numberparts :: GenParser Char JournalContext (String,String)
|
||
-- numberparts = numberpartsstartingwithdigit <|> numberpartsstartingwithpoint
|
||
|
||
-- numberpartsstartingwithdigit :: GenParser Char JournalContext (String,String)
|
||
-- numberpartsstartingwithdigit = do
|
||
-- let digitorcomma = digit <|> char ','
|
||
-- first <- digit
|
||
-- rest <- many digitorcomma
|
||
-- frac <- try (do {char '.'; many digit}) <|> return ""
|
||
-- return (first:rest,frac)
|
||
|
||
-- numberpartsstartingwithpoint :: GenParser Char JournalContext (String,String)
|
||
-- numberpartsstartingwithpoint = do
|
||
-- char '.'
|
||
-- frac <- many1 digit
|
||
-- return ("",frac)
|
||
|
||
-- | Parse a numeric quantity for its value and display attributes. Some
|
||
-- international number formats (cf
|
||
-- http://en.wikipedia.org/wiki/Decimal_separator) are accepted: either
|
||
-- period or comma may be used for the decimal point, and the other of
|
||
-- these may be used for separating digit groups in the integer part (eg a
|
||
-- thousands separator). This returns the numeric value, the precision
|
||
-- (number of digits to the right of the decimal point), the decimal point
|
||
-- and separator characters (defaulting to . and ,), and the positions of
|
||
-- separators (counting leftward from the decimal point, the last is
|
||
-- assumed to repeat).
|
||
number :: GenParser Char JournalContext (Quantity, Int, Char, Char, [Int])
|
||
number = do
|
||
sign <- optionMaybe $ string "-"
|
||
parts <- many1 $ choice' [many1 digit, many1 $ char ',', many1 $ char '.']
|
||
let numeric = isNumber . headDef '_'
|
||
(_, puncparts) = partition numeric parts
|
||
(ok,decimalpoint',separator') =
|
||
case puncparts of
|
||
[] -> (True, Nothing, Nothing) -- no punctuation chars
|
||
[d:""] -> (True, Just d, Nothing) -- just one punctuation char, assume it's a decimal point
|
||
[_] -> (False, Nothing, Nothing) -- adjacent punctuation chars, not ok
|
||
_:_:_ -> let (s:ss, d) = (init puncparts, last puncparts) -- two or more punctuation chars
|
||
in if (any ((/=1).length) puncparts -- adjacent punctuation chars, not ok
|
||
|| any (s/=) ss -- separator chars differ, not ok
|
||
|| head parts == s) -- number begins with a separator char, not ok
|
||
then (False, Nothing, Nothing)
|
||
else if s == d
|
||
then (True, Nothing, Just $ head s) -- just one kind of punctuation, assume separator chars
|
||
else (True, Just $ head d, Just $ head s) -- separators and a decimal point
|
||
when (not ok) (fail $ "number seems ill-formed: "++concat parts)
|
||
let (intparts',fracparts') = span ((/= decimalpoint') . Just . head) parts
|
||
(intparts, fracpart) = (filter numeric intparts', filter numeric fracparts')
|
||
separatorpositions = reverse $ map length $ drop 1 intparts
|
||
int = concat $ "":intparts
|
||
frac = concat $ "":fracpart
|
||
precision = length frac
|
||
int' = if null int then "0" else int
|
||
frac' = if null frac then "0" else frac
|
||
sign' = fromMaybe "" sign
|
||
quantity = read $ sign'++int'++"."++frac' -- this read should never fail
|
||
(decimalpoint, separator) = case (decimalpoint', separator') of (Just d, Just s) -> (d,s)
|
||
(Just '.',Nothing) -> ('.',',')
|
||
(Just ',',Nothing) -> (',','.')
|
||
(Nothing, Just '.') -> (',','.')
|
||
(Nothing, Just ',') -> ('.',',')
|
||
_ -> ('.',',')
|
||
return (quantity,precision,decimalpoint,separator,separatorpositions)
|
||
<?> "number"
|
||
|
||
tests_Hledger_Read_JournalReader = TestList [
|
||
|
||
"number" ~: do
|
||
let s `is` n = assertParseEqual (parseWithCtx nullctx number s) n
|
||
assertFails = assertBool "" . isLeft . parseWithCtx nullctx number
|
||
assertFails ""
|
||
"0" `is` (0, 0, '.', ',', [])
|
||
"1" `is` (1, 0, '.', ',', [])
|
||
"1.1" `is` (1.1, 1, '.', ',', [])
|
||
"1,000.1" `is` (1000.1, 1, '.', ',', [3])
|
||
"1.00.000,1" `is` (100000.1, 1, ',', '.', [3,2])
|
||
"1,000,000" `is` (1000000, 0, '.', ',', [3,3])
|
||
"1." `is` (1, 0, '.', ',', [])
|
||
"1," `is` (1, 0, ',', '.', [])
|
||
".1" `is` (0.1, 1, '.', ',', [])
|
||
",1" `is` (0.1, 1, ',', '.', [])
|
||
assertFails "1,000.000,1"
|
||
assertFails "1.000,000.1"
|
||
assertFails "1,000.000.1"
|
||
assertFails "1,,1"
|
||
assertFails "1..1"
|
||
assertFails ".1,"
|
||
assertFails ",1."
|
||
|
||
,"ledgerTransaction" ~: do
|
||
assertParseEqual (parseWithCtx nullctx ledgerTransaction entry1_str) entry1
|
||
assertBool "ledgerTransaction should not parse just a date"
|
||
$ isLeft $ parseWithCtx nullctx ledgerTransaction "2009/1/1\n"
|
||
assertBool "ledgerTransaction should require some postings"
|
||
$ isLeft $ parseWithCtx nullctx ledgerTransaction "2009/1/1 a\n"
|
||
let t = parseWithCtx nullctx ledgerTransaction "2009/1/1 a ;comment\n b 1\n"
|
||
assertBool "ledgerTransaction should not include a comment in the description"
|
||
$ either (const False) ((== "a") . tdescription) t
|
||
|
||
,"ledgerModifierTransaction" ~: do
|
||
assertParse (parseWithCtx nullctx ledgerModifierTransaction "= (some value expr)\n some:postings 1\n")
|
||
|
||
,"ledgerPeriodicTransaction" ~: do
|
||
assertParse (parseWithCtx nullctx ledgerPeriodicTransaction "~ (some period expr)\n some:postings 1\n")
|
||
|
||
,"ledgerDirective" ~: do
|
||
assertParse (parseWithCtx nullctx ledgerDirective "!include /some/file.x\n")
|
||
assertParse (parseWithCtx nullctx ledgerDirective "account some:account\n")
|
||
assertParse (parseWithCtx nullctx (ledgerDirective >> ledgerDirective) "!account a\nend\n")
|
||
|
||
,"ledgercommentline" ~: do
|
||
assertParse (parseWithCtx nullctx ledgercommentline "; some comment \n")
|
||
assertParse (parseWithCtx nullctx ledgercommentline " \t; x\n")
|
||
assertParse (parseWithCtx nullctx ledgercommentline ";x")
|
||
|
||
,"ledgerdate" ~: do
|
||
assertParse (parseWithCtx nullctx ledgerdate "2011/1/1")
|
||
assertParseFailure (parseWithCtx nullctx ledgerdate "1/1")
|
||
assertParse (parseWithCtx nullctx{ctxYear=Just 2011} ledgerdate "1/1")
|
||
|
||
,"ledgerdatetime" ~: do
|
||
let p = do {t <- ledgerdatetime; eof; return t}
|
||
bad = assertParseFailure . parseWithCtx nullctx p
|
||
good = assertParse . parseWithCtx nullctx p
|
||
bad "2011/1/1"
|
||
bad "2011/1/1 24:00:00"
|
||
bad "2011/1/1 00:60:00"
|
||
bad "2011/1/1 00:00:60"
|
||
good "2011/1/1 00:00"
|
||
good "2011/1/1 23:59:59"
|
||
good "2011/1/1 3:5:7"
|
||
-- timezone is parsed but ignored
|
||
let startofday = LocalTime (fromGregorian 2011 1 1) (TimeOfDay 0 0 (fromIntegral 0))
|
||
assertParseEqual (parseWithCtx nullctx p "2011/1/1 00:00-0800") startofday
|
||
assertParseEqual (parseWithCtx nullctx p "2011/1/1 00:00+1234") startofday
|
||
|
||
,"ledgerDefaultYear" ~: do
|
||
assertParse (parseWithCtx nullctx ledgerDefaultYear "Y 2010\n")
|
||
assertParse (parseWithCtx nullctx ledgerDefaultYear "Y 10001\n")
|
||
|
||
,"ledgerHistoricalPrice" ~:
|
||
assertParseEqual (parseWithCtx nullctx ledgerHistoricalPrice "P 2004/05/01 XYZ $55.00\n") (HistoricalPrice (parsedate "2004/05/01") "XYZ" $ Mixed [dollars 55])
|
||
|
||
,"ledgerIgnoredPriceCommodity" ~: do
|
||
assertParse (parseWithCtx nullctx ledgerIgnoredPriceCommodity "N $\n")
|
||
|
||
,"ledgerDefaultCommodity" ~: do
|
||
assertParse (parseWithCtx nullctx ledgerDefaultCommodity "D $1,000.0\n")
|
||
|
||
,"ledgerCommodityConversion" ~: do
|
||
assertParse (parseWithCtx nullctx ledgerCommodityConversion "C 1h = $50.00\n")
|
||
|
||
,"ledgerTagDirective" ~: do
|
||
assertParse (parseWithCtx nullctx ledgerTagDirective "tag foo \n")
|
||
|
||
,"ledgerEndTagDirective" ~: do
|
||
assertParse (parseWithCtx nullctx ledgerEndTagDirective "end tag \n")
|
||
,"ledgerEndTagDirective" ~: do
|
||
assertParse (parseWithCtx nullctx ledgerEndTagDirective "pop \n")
|
||
|
||
,"ledgeraccountname" ~: do
|
||
assertBool "ledgeraccountname parses a normal accountname" (isRight $ parsewith ledgeraccountname "a:b:c")
|
||
assertBool "ledgeraccountname rejects an empty inner component" (isLeft $ parsewith ledgeraccountname "a::c")
|
||
assertBool "ledgeraccountname rejects an empty leading component" (isLeft $ parsewith ledgeraccountname ":b:c")
|
||
assertBool "ledgeraccountname rejects an empty trailing component" (isLeft $ parsewith ledgeraccountname "a:b:")
|
||
|
||
,"ledgerposting" ~: do
|
||
assertParseEqual (parseWithCtx nullctx ledgerposting " expenses:food:dining $10.00\n")
|
||
(Posting False "expenses:food:dining" (Mixed [dollars 10]) "" RegularPosting [] Nothing)
|
||
assertBool "ledgerposting parses a quoted commodity with numbers"
|
||
(isRight $ parseWithCtx nullctx ledgerposting " a 1 \"DE123\"\n")
|
||
|
||
,"someamount" ~: do
|
||
let -- | compare a parse result with a MixedAmount, showing the debug representation for clarity
|
||
assertMixedAmountParse parseresult mixedamount =
|
||
(either (const "parse error") showMixedAmountDebug parseresult) ~?= (showMixedAmountDebug mixedamount)
|
||
assertMixedAmountParse (parseWithCtx nullctx someamount "1 @ $2")
|
||
(Mixed [Amount unknown 1 (Just $ UnitPrice $ Mixed [Amount dollar{precision=0} 2 Nothing])])
|
||
|
||
,"postingamount" ~: do
|
||
assertParseEqual (parseWithCtx nullctx postingamount " $47.18") (Mixed [dollars 47.18])
|
||
assertParseEqual (parseWithCtx nullctx postingamount " $1.")
|
||
(Mixed [Amount Commodity {symbol="$",side=L,spaced=False,decimalpoint='.',precision=0,separator=',',separatorpositions=[]} 1 Nothing])
|
||
,"postingamount with unit price" ~: do
|
||
assertParseEqual
|
||
(parseWithCtx nullctx postingamount " $10 @ €0.5")
|
||
(Mixed [Amount{commodity=dollar{precision=0},
|
||
quantity=10,
|
||
price=(Just $ UnitPrice $ Mixed [Amount{commodity=euro{precision=1},
|
||
quantity=0.5,
|
||
price=Nothing}])}])
|
||
,"postingamount with total price" ~: do
|
||
assertParseEqual
|
||
(parseWithCtx nullctx postingamount " $10 @@ €5")
|
||
(Mixed [Amount{commodity=dollar{precision=0},
|
||
quantity=10,
|
||
price=(Just $ TotalPrice $ Mixed [Amount{commodity=euro{precision=0},
|
||
quantity=5,
|
||
price=Nothing}])}])
|
||
|
||
,"leftsymbolamount" ~: do
|
||
assertParseEqual (parseWithCtx nullctx leftsymbolamount "$1")
|
||
(Mixed [Amount Commodity {symbol="$",side=L,spaced=False,decimalpoint='.',precision=0,separator=',',separatorpositions=[]} 1 Nothing])
|
||
assertParseEqual (parseWithCtx nullctx leftsymbolamount "$-1")
|
||
(Mixed [Amount Commodity {symbol="$",side=L,spaced=False,decimalpoint='.',precision=0,separator=',',separatorpositions=[]} (-1) Nothing])
|
||
assertParseEqual (parseWithCtx nullctx leftsymbolamount "-$1")
|
||
(Mixed [Amount Commodity {symbol="$",side=L,spaced=False,decimalpoint='.',precision=0,separator=',',separatorpositions=[]} (-1) Nothing])
|
||
|
||
]
|
||
|
||
entry1_str = unlines
|
||
["2007/01/28 coopportunity"
|
||
," expenses:food:groceries $47.18"
|
||
," assets:checking $-47.18"
|
||
,""
|
||
]
|
||
|
||
entry1 =
|
||
txnTieKnot $ Transaction (parsedate "2007/01/28") Nothing False "" "coopportunity" "" []
|
||
[Posting False "expenses:food:groceries" (Mixed [dollars 47.18]) "" RegularPosting [] Nothing,
|
||
Posting False "assets:checking" (Mixed [dollars (-47.18)]) "" RegularPosting [] Nothing] ""
|
||
|