diff --git a/hledger-lib/Hledger/Data/Dates.hs b/hledger-lib/Hledger/Data/Dates.hs index f4af2e1ab..a99b02630 100644 --- a/hledger-lib/Hledger/Data/Dates.hs +++ b/hledger-lib/Hledger/Data/Dates.hs @@ -105,7 +105,7 @@ import Safe (headMay, lastMay, maximumMay, minimumMay) import Text.Megaparsec import Text.Megaparsec.Char (char, char', digitChar, string, string') import Text.Megaparsec.Char.Lexer (decimal, signed) -import Text.Megaparsec.Custom (customErrorBundlePretty) +import Text.Megaparsec.Custom (customErrorBundlePretty, HledgerParseErrors) import Text.Printf (printf) import Hledger.Data.Types @@ -360,7 +360,7 @@ latestSpanContaining datespans = go -- | Parse a period expression to an Interval and overall DateSpan using -- the provided reference date, or return a parse error. parsePeriodExpr - :: Day -> Text -> Either (ParseErrorBundle Text CustomErr) (Interval, DateSpan) + :: Day -> Text -> Either HledgerParseErrors (Interval, DateSpan) parsePeriodExpr refdate s = parsewith (periodexprp refdate <* eof) (T.toLower s) -- | Like parsePeriodExpr, but call error' on failure. @@ -408,14 +408,14 @@ spanFromSmartDate refdate sdate = DateSpan (Just b) (Just e) fixSmartDateStr :: Day -> Text -> Text fixSmartDateStr d s = either (error' . printf "could not parse date %s %s" (show s) . show) id $ -- PARTIAL: - (fixSmartDateStrEither d s :: Either (ParseErrorBundle Text CustomErr) Text) + (fixSmartDateStrEither d s :: Either HledgerParseErrors Text) -- | A safe version of fixSmartDateStr. -fixSmartDateStrEither :: Day -> Text -> Either (ParseErrorBundle Text CustomErr) Text +fixSmartDateStrEither :: Day -> Text -> Either HledgerParseErrors Text fixSmartDateStrEither d = fmap showDate . fixSmartDateStrEither' d fixSmartDateStrEither' - :: Day -> Text -> Either (ParseErrorBundle Text CustomErr) Day + :: Day -> Text -> Either HledgerParseErrors Day fixSmartDateStrEither' d s = case parsewith smartdateonly (T.toLower s) of Right sd -> Right $ fixSmartDate d sd Left e -> Left e diff --git a/hledger-lib/Hledger/Data/Journal.hs b/hledger-lib/Hledger/Data/Journal.hs index 222dbea97..e4ab359f5 100644 --- a/hledger-lib/Hledger/Data/Journal.hs +++ b/hledger-lib/Hledger/Data/Journal.hs @@ -142,13 +142,13 @@ import Hledger.Query -- | A parser of text that runs in some monad, keeping a Journal as state. -type JournalParser m a = StateT Journal (ParsecT CustomErr Text m) a +type JournalParser m a = StateT Journal (ParsecT HledgerParseErrorData Text m) a -- | A parser of text that runs in some monad, keeping a Journal as -- state, that can throw an exception to end parsing, preventing -- further parser backtracking. type ErroringJournalParser m a = - StateT Journal (ParsecT CustomErr Text (ExceptT FinalParseError m)) a + StateT Journal (ParsecT HledgerParseErrorData Text (ExceptT FinalParseError m)) a -- deriving instance Show Journal instance Show Journal where diff --git a/hledger-lib/Hledger/Data/PeriodicTransaction.hs b/hledger-lib/Hledger/Data/PeriodicTransaction.hs index 340f19776..40358340b 100644 --- a/hledger-lib/Hledger/Data/PeriodicTransaction.hs +++ b/hledger-lib/Hledger/Data/PeriodicTransaction.hs @@ -189,20 +189,20 @@ instance Show PeriodicTransaction where -- -- -- >>> _ptgen "" --- *** Exception: failed to parse... +-- *** Exception: Error: failed to parse... -- ... -- -- >>> _ptgen "weekly from 2017" --- *** Exception: Unable to generate transactions according to "weekly from 2017" because 2017-01-01 is not a first day of the Week +-- *** Exception: Error: Unable to generate transactions according to "weekly from 2017" because 2017-01-01 is not a first day of the Week -- -- >>> _ptgen "monthly from 2017/5/4" --- *** Exception: Unable to generate transactions according to "monthly from 2017/5/4" because 2017-05-04 is not a first day of the Month +-- *** Exception: Error: Unable to generate transactions according to "monthly from 2017/5/4" because 2017-05-04 is not a first day of the Month -- -- >>> _ptgen "every quarter from 2017/1/2" --- *** Exception: Unable to generate transactions according to "every quarter from 2017/1/2" because 2017-01-02 is not a first day of the Quarter +-- *** Exception: Error: Unable to generate transactions according to "every quarter from 2017/1/2" because 2017-01-02 is not a first day of the Quarter -- -- >>> _ptgen "yearly from 2017/1/14" --- *** Exception: Unable to generate transactions according to "yearly from 2017/1/14" because 2017-01-14 is not a first day of the Year +-- *** Exception: Error: Unable to generate transactions according to "yearly from 2017/1/14" because 2017-01-14 is not a first day of the Year -- -- >>> let reportperiod="daily from 2018/01/03" in let (i,s) = parsePeriodExpr' nulldate reportperiod in runPeriodicTransaction (nullperiodictransaction{ptperiodexpr=reportperiod, ptspan=s, ptinterval=i, ptpostings=["a" `post` usd 1]}) (DateSpan (Just $ fromGregorian 2018 01 01) (Just $ fromGregorian 2018 01 03)) -- [] diff --git a/hledger-lib/Hledger/Data/Transaction.hs b/hledger-lib/Hledger/Data/Transaction.hs index 15de153bd..4ce0ce0d7 100644 --- a/hledger-lib/Hledger/Data/Transaction.hs +++ b/hledger-lib/Hledger/Data/Transaction.hs @@ -41,6 +41,7 @@ module Hledger.Data.Transaction -- * rendering , showTransaction , showTransactionOneLineAmounts +, showTransactionLineFirstPart , transactionFile -- * tests , tests_Transaction @@ -137,18 +138,22 @@ showTransactionHelper onelineamounts t = <> foldMap ((<> newline) . TB.fromText) (postingsAsLines onelineamounts $ tpostings t) <> newline where - descriptionline = T.stripEnd $ T.concat [date, status, code, desc, samelinecomment] - date = showDate (tdate t) <> maybe "" (("="<>) . showDate) (tdate2 t) - status | tstatus t == Cleared = " *" - | tstatus t == Pending = " !" - | otherwise = "" - code = if T.null (tcode t) then "" else wrap " (" ")" $ tcode t + descriptionline = T.stripEnd $ showTransactionLineFirstPart t <> T.concat [desc, samelinecomment] desc = if T.null d then "" else " " <> d where d = tdescription t (samelinecomment, newlinecomments) = case renderCommentLines (tcomment t) of [] -> ("",[]) c:cs -> (c,cs) newline = TB.singleton '\n' +-- Useful when rendering error messages. +showTransactionLineFirstPart t = T.concat [date, status, code] + where + date = showDate (tdate t) <> maybe "" (("="<>) . showDate) (tdate2 t) + status | tstatus t == Cleared = " *" + | tstatus t == Pending = " !" + | otherwise = "" + code = if T.null (tcode t) then "" else wrap " (" ")" $ tcode t + hasRealPostings :: Transaction -> Bool hasRealPostings = not . null . realPostings diff --git a/hledger-lib/Hledger/Read/Common.hs b/hledger-lib/Hledger/Read/Common.hs index e3639dbb2..a10dd973e 100644 --- a/hledger-lib/Hledger/Read/Common.hs +++ b/hledger-lib/Hledger/Read/Common.hs @@ -112,6 +112,8 @@ module Hledger.Read.Common ( skipNonNewlineSpaces, skipNonNewlineSpaces1, aliasesFromOpts, + makeTransactionErrorExcerpt, + makePostingErrorExcerpt, -- * tests tests_Common, @@ -144,7 +146,7 @@ import Text.Megaparsec import Text.Megaparsec.Char (char, char', digitChar, newline, string) import Text.Megaparsec.Char.Lexer (decimal) import Text.Megaparsec.Custom - (FinalParseError, attachSource, customErrorBundlePretty, finalErrorBundlePretty, parseErrorAt, parseErrorAtRegion) + (FinalParseError, attachSource, customErrorBundlePretty, finalErrorBundlePretty, parseErrorAt, parseErrorAtRegion, HledgerParseErrors) import Hledger.Data import Hledger.Query (Query(..), filterQuery, parseQueryTerm, queryEndDate, queryStartDate, queryIsDate, simplifyQuery) @@ -152,6 +154,7 @@ import Hledger.Reports.ReportOptions (ReportOpts(..), queryFromFlags, rawOptsToR import Hledger.Utils import Text.Printf (printf) import Hledger.Read.InputOptions +import Safe (atMay) --- ** doctest setup -- $setup @@ -271,7 +274,7 @@ initialiseAndParseJournal parser iopts f txt = y = first3 . toGregorian $ _ioDay iopts initJournal = nulljournal{jparsedefaultyear = Just y, jincludefilestack = [f]} -- Flatten parse errors and final parse errors, and output each as a pretty String. - prettyParseErrors :: ExceptT FinalParseError IO (Either (ParseErrorBundle Text CustomErr) a) + prettyParseErrors :: ExceptT FinalParseError IO (Either (ParseErrorBundle Text HledgerParseErrorData) a) -> ExceptT String IO a prettyParseErrors = withExceptT customErrorBundlePretty . liftEither <=< withExceptT (finalErrorBundlePretty . attachSource f txt) @@ -362,59 +365,173 @@ journalCheckPayeesDeclared :: Journal -> Either String () journalCheckPayeesDeclared j = mapM_ checkpayee (jtxns j) where checkpayee t - | p `elem` ps = Right () + | payee `elem` journalPayeesDeclared j = Right () | otherwise = Left $ - printf "undeclared payee \"%s\"\nat: %s\n\n%s" - (T.unpack p) - (sourcePosPairPretty $ tsourcepos t) - (linesPrepend2 "> " " " . (<>"\n") . textChomp $ showTransaction t) + printf "%s:%d:%d-%d:\n%sundeclared payee \"%s\"\n" f l col col2 ex payee where - p = transactionPayee t - ps = journalPayeesDeclared j + payee = transactionPayee t + (f,l,mcols,ex) = makeTransactionErrorExcerpt t finderrcols + col = maybe 0 fst mcols + col2 = maybe 0 (fromMaybe 0 . snd) mcols + finderrcols t = Just (col, Just col2) + where + col = T.length (showTransactionLineFirstPart t) + 2 + col2 = col + T.length (transactionPayee t) - 1 -- | Check that all the journal's postings are to accounts declared with -- account directives, returning an error message otherwise. journalCheckAccountsDeclared :: Journal -> Either String () journalCheckAccountsDeclared j = mapM_ checkacct (journalPostings j) where - checkacct Posting{paccount,ptransaction} - | paccount `elem` as = Right () - | otherwise = Left $ - (printf "undeclared account \"%s\"\n" (T.unpack paccount)) - ++ case ptransaction of - Nothing -> "" - Just t -> printf "in transaction at: %s\n\n%s" - (sourcePosPairPretty $ tsourcepos t) - (linesPrepend " " . (<>"\n") . textChomp $ showTransaction t) - where - as = journalAccountNamesDeclared j + checkacct p@Posting{paccount=a} + | a `elem` journalAccountNamesDeclared j = Right () + | otherwise = Left $ + printf "%s:%d:%d-%d:\n%sundeclared account \"%s\"\n" f l col col2 ex a + where + (f,l,mcols,ex) = makePostingErrorExcerpt p finderrcols + col = maybe 0 fst mcols + col2 = maybe 0 (fromMaybe 0 . snd) mcols + finderrcols p _ _ = Just (col, Just col2) + where + col = 5 + if isVirtual p then 1 else 0 + col2 = col + T.length a - 1 -- | Check that all the commodities used in this journal's postings have been declared -- by commodity directives, returning an error message otherwise. journalCheckCommoditiesDeclared :: Journal -> Either String () -journalCheckCommoditiesDeclared j = - mapM_ checkcommodities (journalPostings j) +journalCheckCommoditiesDeclared j = mapM_ checkcommodities (journalPostings j) where - checkcommodities Posting{..} = - case mfirstundeclaredcomm of + checkcommodities p = + case findundeclaredcomm p of Nothing -> Right () - Just c -> Left $ - (printf "undeclared commodity \"%s\"\n" (T.unpack c)) - ++ case ptransaction of - Nothing -> "" - Just t -> printf "in transaction at: %s\n\n%s" - (sourcePosPairPretty $ tsourcepos t) - (linesPrepend " " . (<>"\n") . textChomp $ showTransaction t) + Just (comm, _) -> + Left $ printf "%s:%d:%d-%d:\n%sundeclared commodity \"%s\"\n" f l col col2 ex comm + where + (f,l,mcols,ex) = makePostingErrorExcerpt p finderrcols + col = maybe 0 fst mcols + col2 = maybe 0 (fromMaybe 0 . snd) mcols where - mfirstundeclaredcomm = - find (`M.notMember` jcommodities j) - . map acommodity - . (maybe id ((:) . baamount) pbalanceassertion) - . filter (not . isIgnorable) - $ amountsRaw pamount + -- Find the first undeclared commodity symbol in this posting's amount + -- or balance assertion amount, if any. The boolean will be true if + -- the undeclared symbol was in the posting amount. + findundeclaredcomm :: Posting -> Maybe (CommoditySymbol, Bool) + findundeclaredcomm Posting{pamount=amt,pbalanceassertion} = + case (findundeclared postingcomms, findundeclared assertioncomms) of + (Just c, _) -> Just (c, True) + (_, Just c) -> Just (c, False) + _ -> Nothing + where + postingcomms = map acommodity $ filter (not . isIgnorable) $ amountsRaw amt + where + -- Ignore missing amounts and zero amounts without commodity (#1767) + isIgnorable a = (T.null (acommodity a) && amountIsZero a) || a == missingamt + assertioncomms = [acommodity a | Just a <- [baamount <$> pbalanceassertion]] + findundeclared = find (`M.notMember` jcommodities j) - -- Ignore missing amounts and zero amounts without commodity (#1767) - isIgnorable a = (T.null (acommodity a) && amountIsZero a) || a == missingamt + -- Find the best position for an error column marker when this posting + -- is rendered by showTransaction. + -- Reliably locating a problem commodity symbol in showTransaction output + -- is really tricky. Some examples: + -- + -- assets "C $" -1 @ $ 2 + -- ^ + -- assets $1 = $$1 + -- ^ + -- assets [ANSI RED]$-1[ANSI RESET] + -- ^ + -- + -- To simplify, we will mark the whole amount + balance assertion region, like: + -- assets "C $" -1 @ $ 2 + -- ^^^^^^^^^^^^^^ + finderrcols p t txntxt = + case transactionFindPostingIndex (==p) t of + Nothing -> Nothing + Just pindex -> Just (amtstart, Just amtend) + where + tcommentlines = max 0 (length (T.lines $ tcomment t) - 1) + errrelline = 1 + tcommentlines + pindex -- XXX doesn't count posting coment lines + errline = fromMaybe "" (T.lines txntxt `atMay` (errrelline-1)) + acctend = 4 + T.length (paccount p) + if isVirtual p then 2 else 0 + amtstart = acctend + (T.length $ T.takeWhile isSpace $ T.drop acctend errline) + 1 + amtend = amtstart + (T.length $ T.stripEnd $ T.takeWhile (/=';') $ T.drop amtstart errline) + +-- | Given a problem transaction and a function calculating the best +-- column(s) for marking the error region: +-- render it as a megaparsec-style excerpt, showing the original line number +-- on the transaction line, and a column(s) marker. +-- Returns the file path, line number, column(s) if known, +-- and the rendered excerpt, or as much of these as is possible. +makeTransactionErrorExcerpt :: Transaction -> (Transaction -> Maybe (Int, Maybe Int)) -> (FilePath, Int, Maybe (Int, Maybe Int), Text) +makeTransactionErrorExcerpt t findtxnerrorcolumns = (f, tl, merrcols, ex) + -- XXX findtxnerrorcolumns is awkward, I don't think this is the final form + where + (SourcePos f tpos _) = fst $ tsourcepos t + tl = unPos tpos + txntxt = showTransaction t & textChomp & (<>"\n") + merrcols = findtxnerrorcolumns t + ex = decorateTransactionErrorExcerpt tl merrcols txntxt + +-- | Add megaparsec-style left margin, line number, and optional column marker(s). +decorateTransactionErrorExcerpt :: Int -> Maybe (Int, Maybe Int) -> Text -> Text +decorateTransactionErrorExcerpt l mcols txt = + T.unlines $ ls' <> colmarkerline <> map (lineprefix<>) ms + where + (ls,ms) = splitAt 1 $ T.lines txt + ls' = map ((T.pack (show l) <> " | ") <>) ls + colmarkerline = + [lineprefix <> T.replicate (col-1) " " <> T.replicate regionw "^" + | Just (col, mendcol) <- [mcols] + , let regionw = maybe 1 (subtract col) mendcol + 1 + ] + lineprefix = T.replicate marginw " " <> "| " + where marginw = length (show l) + 1 + +-- | Given a problem posting and a function calculating the best +-- column(s) for marking the error region: +-- look up error info from the parent transaction, and render the transaction +-- as a megaparsec-style excerpt, showing the original line number +-- on the problem posting's line, and a column indicator. +-- Returns the file path, line number, column(s) if known, +-- and the rendered excerpt, or as much of these as is possible. +makePostingErrorExcerpt :: Posting -> (Posting -> Transaction -> Text -> Maybe (Int, Maybe Int)) -> (FilePath, Int, Maybe (Int, Maybe Int), Text) +makePostingErrorExcerpt p findpostingerrorcolumns = + case ptransaction p of + Nothing -> ("-", 0, Nothing, "") + Just t -> (f, errabsline, merrcols, ex) + where + (SourcePos f tl _) = fst $ tsourcepos t + tcommentlines = max 0 (length (T.lines $ tcomment t) - 1) + mpindex = transactionFindPostingIndex (==p) t + errrelline = maybe 0 (tcommentlines+) mpindex -- XXX doesn't count posting coment lines + errabsline = unPos tl + errrelline + txntxt = showTransaction t & textChomp & (<>"\n") + merrcols = findpostingerrorcolumns p t txntxt + ex = decoratePostingErrorExcerpt errabsline errrelline merrcols txntxt + +-- | Add megaparsec-style left margin, line number, and optional column marker(s). +decoratePostingErrorExcerpt :: Int -> Int -> Maybe (Int, Maybe Int) -> Text -> Text +decoratePostingErrorExcerpt absline relline mcols txt = + T.unlines $ js' <> ks' <> colmarkerline <> ms' + where + (ls,ms) = splitAt (relline+1) $ T.lines txt + (js,ks) = splitAt (length ls - 1) ls + (js',ks') = case ks of + [k] -> (map (lineprefix<>) js, [T.pack (show absline) <> " | " <> k]) + _ -> ([], []) + ms' = map (lineprefix<>) ms + colmarkerline = + [lineprefix <> T.replicate (col-1) " " <> T.replicate regionw "^" + | Just (col, mendcol) <- [mcols] + , let regionw = 1 + maybe 0 (subtract col) mendcol + ] + lineprefix = T.replicate marginw " " <> "| " + where marginw = length (show absline) + 1 + +-- | Find the 1-based index of the first posting in this transaction +-- satisfying the given predicate. +transactionFindPostingIndex :: (Posting -> Bool) -> Transaction -> Maybe Int +transactionFindPostingIndex ppredicate = + fmap fst . find (ppredicate.snd) . zip [1..] . tpostings setYear :: Year -> JournalParser m () setYear y = modify' (\j -> j{jparsedefaultyear=Just y}) @@ -855,7 +972,7 @@ amountwithoutpricep mult = do Right (q,p,d,g) -> pure (q, Precision p, d, g) -- | Try to parse an amount from a string -amountp'' :: String -> Either (ParseErrorBundle Text CustomErr) Amount +amountp'' :: String -> Either HledgerParseErrors Amount amountp'' s = runParser (evalStateT (amountp <* eof) nulljournal) "" (T.pack s) -- | Parse an amount from a string, or get an error. diff --git a/hledger-lib/Hledger/Read/CsvReader.hs b/hledger-lib/Hledger/Read/CsvReader.hs index 303167c99..80897ec55 100644 --- a/hledger-lib/Hledger/Read/CsvReader.hs +++ b/hledger-lib/Hledger/Read/CsvReader.hs @@ -216,7 +216,7 @@ parseAndValidateCsvRules rulesfile s = parseErrorPretty (FancyError 0 (S.singleton $ ErrorFail errorString) :: ParseError Text String) -- | Parse this text as CSV conversion rules. The file path is for error messages. -parseCsvRules :: FilePath -> T.Text -> Either (ParseErrorBundle T.Text CustomErr) CsvRules +parseCsvRules :: FilePath -> T.Text -> Either (ParseErrorBundle T.Text HledgerParseErrorData) CsvRules -- parseCsvRules rulesfile s = runParser csvrulesfile nullrules{baseAccount=takeBaseName rulesfile} rulesfile s parseCsvRules = runParser (evalStateT rulesp defrules) @@ -1232,7 +1232,7 @@ renderTemplate rules record t = maybe t mconcat $ parseMaybe <|> replaceCsvFieldReference rules record <$> referencep) t where - referencep = liftA2 T.cons (char '%') (takeWhile1P (Just "reference") isFieldNameChar) :: Parsec CustomErr Text Text + referencep = liftA2 T.cons (char '%') (takeWhile1P (Just "reference") isFieldNameChar) :: Parsec HledgerParseErrorData Text Text isFieldNameChar c = isAlphaNum c || c == '_' || c == '-' -- | Replace something that looks like a reference to a csv field ("%date" or "%1) diff --git a/hledger-lib/Hledger/Read/JournalReader.hs b/hledger-lib/Hledger/Read/JournalReader.hs index 42902c37c..6377a9835 100644 --- a/hledger-lib/Hledger/Read/JournalReader.hs +++ b/hledger-lib/Hledger/Read/JournalReader.hs @@ -113,7 +113,7 @@ import qualified Hledger.Read.CsvReader as CsvReader (reader) -- | Run a journal parser in some monad. See also: parseWithState. runJournalParser, rjp :: Monad m - => JournalParser m a -> Text -> m (Either (ParseErrorBundle Text CustomErr) a) + => JournalParser m a -> Text -> m (Either HledgerParseErrors a) runJournalParser p = runParserT (evalStateT p nulljournal) "" rjp = runJournalParser @@ -122,7 +122,7 @@ runErroringJournalParser, rejp :: Monad m => ErroringJournalParser m a -> Text - -> m (Either FinalParseError (Either (ParseErrorBundle Text CustomErr) a)) + -> m (Either FinalParseError (Either HledgerParseErrors a)) runErroringJournalParser p t = runExceptT $ runParserT (evalStateT p nulljournal) "" t rejp = runErroringJournalParser diff --git a/hledger-lib/Hledger/Reports/ReportOptions.hs b/hledger-lib/Hledger/Reports/ReportOptions.hs index 6d9f8fb10..e213650a0 100644 --- a/hledger-lib/Hledger/Reports/ReportOptions.hs +++ b/hledger-lib/Hledger/Reports/ReportOptions.hs @@ -801,7 +801,7 @@ makeHledgerClassyLenses ''ReportSpec -- >>> _rsQuery $ set querystring ["assets"] defreportspec -- Acct (RegexpCI "assets") -- >>> _rsQuery $ set querystring ["(assets"] defreportspec --- *** Exception: Updating ReportSpec failed: try using overEither instead of over or setEither instead of set +-- *** Exception: Error: Updating ReportSpec failed: try using overEither instead of over or setEither instead of set -- >>> _rsQuery $ set period (MonthPeriod 2021 08) defreportspec -- Date DateSpan 2021-08 class HasReportOptsNoUpdate a => HasReportOpts a where diff --git a/hledger-lib/Hledger/Utils.hs b/hledger-lib/Hledger/Utils.hs index a80abc3ee..1f9a199ec 100644 --- a/hledger-lib/Hledger/Utils.hs +++ b/hledger-lib/Hledger/Utils.hs @@ -252,7 +252,7 @@ numDigitsInt n -- | Simpler alias for errorWithoutStackTrace error' :: String -> a -error' = errorWithoutStackTrace +error' = errorWithoutStackTrace . ("Error: " <>) -- | A version of errorWithoutStackTrace that adds a usage hint. usageError :: String -> a diff --git a/hledger-lib/Hledger/Utils/Parse.hs b/hledger-lib/Hledger/Utils/Parse.hs index e242ed70d..47b21b988 100644 --- a/hledger-lib/Hledger/Utils/Parse.hs +++ b/hledger-lib/Hledger/Utils/Parse.hs @@ -38,7 +38,7 @@ module Hledger.Utils.Parse ( skipNonNewlineSpaces', -- * re-exports - CustomErr + HledgerParseErrorData ) where @@ -54,13 +54,13 @@ import Text.Megaparsec.Custom import Text.Printf -- | A parser of string to some type. -type SimpleStringParser a = Parsec CustomErr String a +type SimpleStringParser a = Parsec HledgerParseErrorData String a -- | A parser of strict text to some type. -type SimpleTextParser = Parsec CustomErr Text -- XXX an "a" argument breaks the CsvRulesParser declaration somehow +type SimpleTextParser = Parsec HledgerParseErrorData Text -- XXX an "a" argument breaks the CsvRulesParser declaration somehow -- | A parser of text that runs in some monad. -type TextParser m a = ParsecT CustomErr Text m a +type TextParser m a = ParsecT HledgerParseErrorData Text m a -- | Render a pair of source positions in human-readable form, only displaying the range of lines. sourcePosPairPretty :: (SourcePos, SourcePos) -> String @@ -76,7 +76,7 @@ choice' = choice . map try -- | Backtracking choice, use this when alternatives share a prefix. -- Consumes no input if all choices fail. -choiceInState :: [StateT s (ParsecT CustomErr Text m) a] -> StateT s (ParsecT CustomErr Text m) a +choiceInState :: [StateT s (ParsecT HledgerParseErrorData Text m) a] -> StateT s (ParsecT HledgerParseErrorData Text m) a choiceInState = choice . map try surroundedBy :: Applicative m => m openclose -> m a -> m a @@ -87,7 +87,7 @@ parsewith p = runParser p "" -- | Run a text parser in the identity monad. See also: parseWithState. runTextParser, rtp - :: TextParser Identity a -> Text -> Either (ParseErrorBundle Text CustomErr) a + :: TextParser Identity a -> Text -> Either HledgerParseErrors a runTextParser = parsewith rtp = runTextParser @@ -100,9 +100,9 @@ parsewithString p = runParser p "" parseWithState :: Monad m => st - -> StateT st (ParsecT CustomErr Text m) a + -> StateT st (ParsecT HledgerParseErrorData Text m) a -> Text - -> m (Either (ParseErrorBundle Text CustomErr) a) + -> m (Either HledgerParseErrors a) parseWithState ctx p = runParserT (evalStateT p ctx) "" parseWithState' @@ -139,7 +139,7 @@ nonspace = satisfy (not . isSpace) isNonNewlineSpace :: Char -> Bool isNonNewlineSpace c = not (isNewline c) && isSpace c -spacenonewline :: (Stream s, Char ~ Token s) => ParsecT CustomErr s m Char +spacenonewline :: (Stream s, Char ~ Token s) => ParsecT HledgerParseErrorData s m Char spacenonewline = satisfy isNonNewlineSpace {-# INLINABLE spacenonewline #-} @@ -147,17 +147,17 @@ restofline :: TextParser m String restofline = anySingle `manyTill` eolof -- Skip many non-newline spaces. -skipNonNewlineSpaces :: (Stream s, Token s ~ Char) => ParsecT CustomErr s m () +skipNonNewlineSpaces :: (Stream s, Token s ~ Char) => ParsecT HledgerParseErrorData s m () skipNonNewlineSpaces = () <$ takeWhileP Nothing isNonNewlineSpace {-# INLINABLE skipNonNewlineSpaces #-} -- Skip many non-newline spaces, failing if there are none. -skipNonNewlineSpaces1 :: (Stream s, Token s ~ Char) => ParsecT CustomErr s m () +skipNonNewlineSpaces1 :: (Stream s, Token s ~ Char) => ParsecT HledgerParseErrorData s m () skipNonNewlineSpaces1 = () <$ takeWhile1P Nothing isNonNewlineSpace {-# INLINABLE skipNonNewlineSpaces1 #-} -- Skip many non-newline spaces, returning True if any have been skipped. -skipNonNewlineSpaces' :: (Stream s, Token s ~ Char) => ParsecT CustomErr s m Bool +skipNonNewlineSpaces' :: (Stream s, Token s ~ Char) => ParsecT HledgerParseErrorData s m Bool skipNonNewlineSpaces' = True <$ skipNonNewlineSpaces1 <|> pure False {-# INLINABLE skipNonNewlineSpaces' #-} diff --git a/hledger-lib/Hledger/Utils/Test.hs b/hledger-lib/Hledger/Utils/Test.hs index 6cdf1af5a..e3fc9bef8 100644 --- a/hledger-lib/Hledger/Utils/Test.hs +++ b/hledger-lib/Hledger/Utils/Test.hs @@ -31,7 +31,7 @@ import Test.Tasty.HUnit -- import Test.Tasty.SmallCheck as SC import Text.Megaparsec import Text.Megaparsec.Custom - ( CustomErr, + ( HledgerParseErrorData, FinalParseError, attachSource, customErrorBundlePretty, @@ -56,7 +56,7 @@ assertRight (Left a) = assertFailure $ "expected Right, got (Left " ++ show a + -- | Run a parser on the given text and display a helpful error. parseHelper :: (HasCallStack, Default st, Monad m) => - StateT st (ParsecT CustomErr T.Text m) a -> T.Text -> ExceptT String m a + StateT st (ParsecT HledgerParseErrorData T.Text m) a -> T.Text -> ExceptT String m a parseHelper parser input = withExceptT (\e -> "\nparse error at " ++ customErrorBundlePretty e ++ "\n") . ExceptT $ runParserT (evalStateT (parser <* eof) def) "" input @@ -65,7 +65,7 @@ parseHelper parser input = -- produce an 'Assertion'. Suitable for hledger's JournalParser parsers. assertParseHelper :: (HasCallStack, Default st) => (String -> Assertion) -> (a -> Assertion) - -> StateT st (ParsecT CustomErr T.Text IO) a -> T.Text + -> StateT st (ParsecT HledgerParseErrorData T.Text IO) a -> T.Text -> Assertion assertParseHelper onFailure onSuccess parser input = either onFailure onSuccess =<< runExceptT (parseHelper parser input) @@ -74,25 +74,25 @@ assertParseHelper onFailure onSuccess parser input = -- all of the given input text, showing the parse error if it fails. -- Suitable for hledger's JournalParser parsers. assertParse :: (HasCallStack, Default st) => - StateT st (ParsecT CustomErr T.Text IO) a -> T.Text -> Assertion + StateT st (ParsecT HledgerParseErrorData T.Text IO) a -> T.Text -> Assertion assertParse = assertParseHelper assertFailure (const $ return ()) -- | Assert a parser produces an expected value. assertParseEq :: (HasCallStack, Eq a, Show a, Default st) => - StateT st (ParsecT CustomErr T.Text IO) a -> T.Text -> a -> Assertion + StateT st (ParsecT HledgerParseErrorData T.Text IO) a -> T.Text -> a -> Assertion assertParseEq parser input = assertParseEqOn parser input id -- | Like assertParseEq, but transform the parse result with the given function -- before comparing it. assertParseEqOn :: (HasCallStack, Eq b, Show b, Default st) => - StateT st (ParsecT CustomErr T.Text IO) a -> T.Text -> (a -> b) -> b -> Assertion + StateT st (ParsecT HledgerParseErrorData T.Text IO) a -> T.Text -> (a -> b) -> b -> Assertion assertParseEqOn parser input f expected = assertParseHelper assertFailure (assertEqual "" expected . f) parser input -- | Assert that this stateful parser runnable in IO fails to parse -- the given input text, with a parse error containing the given string. assertParseError :: (HasCallStack, Eq a, Show a, Default st) => - StateT st (ParsecT CustomErr T.Text IO) a -> T.Text -> String -> Assertion + StateT st (ParsecT HledgerParseErrorData T.Text IO) a -> T.Text -> String -> Assertion assertParseError parser input errstr = assertParseHelper (\e -> unless (errstr `isInfixOf` e) $ assertFailure $ "\nparse error is not as expected:" ++ e) (\v -> assertFailure $ "\nparse succeeded unexpectedly, producing:\n" ++ pshow v ++ "\n") @@ -102,7 +102,7 @@ assertParseError parser input errstr = assertParseHelper -- final state (the wrapped state, not megaparsec's internal state), -- transformed by the given function, matches the given expected value. assertParseStateOn :: (HasCallStack, Eq b, Show b, Default st) => - StateT st (ParsecT CustomErr T.Text IO) a -> T.Text -> (st -> b) -> b -> Assertion + StateT st (ParsecT HledgerParseErrorData T.Text IO) a -> T.Text -> (st -> b) -> b -> Assertion assertParseStateOn parser input f expected = do es <- runParserT (execStateT (parser <* eof) def) "" input case es of @@ -111,7 +111,7 @@ assertParseStateOn parser input f expected = do -- | These "E" variants of the above are suitable for hledger's ErroringJournalParser parsers. parseHelperE :: (HasCallStack, Default st, Monad m) => - StateT st (ParsecT CustomErr T.Text (ExceptT FinalParseError m)) a -> T.Text -> ExceptT String m a + StateT st (ParsecT HledgerParseErrorData T.Text (ExceptT FinalParseError m)) a -> T.Text -> ExceptT String m a parseHelperE parser input = do withExceptT (\e -> "\nparse error at " ++ customErrorBundlePretty e ++ "\n") . liftEither =<< withExceptT (\e -> "parse error at " ++ finalErrorBundlePretty (attachSource "" input e)) @@ -119,30 +119,30 @@ parseHelperE parser input = do assertParseHelperE :: (HasCallStack, Default st) => (String -> Assertion) -> (a -> Assertion) - -> StateT st (ParsecT CustomErr T.Text (ExceptT FinalParseError IO)) a -> T.Text + -> StateT st (ParsecT HledgerParseErrorData T.Text (ExceptT FinalParseError IO)) a -> T.Text -> Assertion assertParseHelperE onFailure onSuccess parser input = either onFailure onSuccess =<< runExceptT (parseHelperE parser input) assertParseE :: (HasCallStack, Eq a, Show a, Default st) - => StateT st (ParsecT CustomErr T.Text (ExceptT FinalParseError IO)) a -> T.Text -> Assertion + => StateT st (ParsecT HledgerParseErrorData T.Text (ExceptT FinalParseError IO)) a -> T.Text -> Assertion assertParseE = assertParseHelperE assertFailure (const $ return ()) assertParseEqE :: (Default st, Eq a, Show a, HasCallStack) - => StateT st (ParsecT CustomErr T.Text (ExceptT FinalParseError IO)) a -> T.Text -> a -> Assertion + => StateT st (ParsecT HledgerParseErrorData T.Text (ExceptT FinalParseError IO)) a -> T.Text -> a -> Assertion assertParseEqE parser input = assertParseEqOnE parser input id assertParseEqOnE :: (HasCallStack, Eq b, Show b, Default st) - => StateT st (ParsecT CustomErr T.Text (ExceptT FinalParseError IO)) a -> T.Text -> (a -> b) -> b -> Assertion + => StateT st (ParsecT HledgerParseErrorData T.Text (ExceptT FinalParseError IO)) a -> T.Text -> (a -> b) -> b -> Assertion assertParseEqOnE parser input f expected = assertParseHelperE assertFailure (assertEqual "" expected . f) parser input assertParseErrorE :: (Default st, Eq a, Show a, HasCallStack) - => StateT st (ParsecT CustomErr T.Text (ExceptT FinalParseError IO)) a -> T.Text -> String -> Assertion + => StateT st (ParsecT HledgerParseErrorData T.Text (ExceptT FinalParseError IO)) a -> T.Text -> String -> Assertion assertParseErrorE parser input errstr = assertParseHelperE (\e -> unless (errstr `isInfixOf` e) $ assertFailure $ "\nparse error is not as expected:" ++ e) (\v -> assertFailure $ "\nparse succeeded unexpectedly, producing:\n" ++ pshow v ++ "\n") diff --git a/hledger-lib/Text/Megaparsec/Custom.hs b/hledger-lib/Text/Megaparsec/Custom.hs index 436cba927..c0230a951 100644 --- a/hledger-lib/Text/Megaparsec/Custom.hs +++ b/hledger-lib/Text/Megaparsec/Custom.hs @@ -7,8 +7,9 @@ {-# LANGUAGE StandaloneDeriving #-} -- new module Text.Megaparsec.Custom ( - -- * Custom parse error type - CustomErr, + -- * Custom parse error types + HledgerParseErrorData, + HledgerParseErrors, -- * Failing with an arbitrary source position parseErrorAt, @@ -55,12 +56,10 @@ import Data.Text (Text) import Text.Megaparsec ---- * Custom parse error type +--- * Custom parse error types --- | A custom error type for the parser. The type is specialized to --- parsers of 'Text' streams. - -data CustomErr +-- | Custom error data for hledger parsers. Specialised for a 'Text' parse stream. +data HledgerParseErrorData -- | Fail with a message at a specific source position interval. The -- interval must be contained within a single line. = ErrorFailAt Int -- Starting offset @@ -69,21 +68,27 @@ data CustomErr -- | Re-throw parse errors obtained from the "re-parsing" of an excerpt -- of the source text. | ErrorReparsing - (NE.NonEmpty (ParseError Text CustomErr)) -- Source fragment parse errors + (NE.NonEmpty (ParseError Text HledgerParseErrorData)) -- Source fragment parse errors deriving (Show, Eq, Ord) +-- | A specialised version of ParseErrorBundle: +-- a non-empty collection of hledger parse errors, +-- equipped with PosState to help pretty-print them. +-- Specialised for a 'Text' parse stream. +type HledgerParseErrors = ParseErrorBundle Text HledgerParseErrorData + -- We require an 'Ord' instance for 'CustomError' so that they may be -- stored in a 'Set'. The actual instance is inconsequential, so we just -- derive it, but the derived instance requires an (orphan) instance for -- 'ParseError'. Hopefully this does not cause any trouble. -deriving instance Ord (ParseError Text CustomErr) +deriving instance Ord (ParseError Text HledgerParseErrorData) --- Note: the pretty-printing of our 'CustomErr' type is only partally +-- Note: the pretty-printing of our 'HledgerParseErrorData' type is only partally -- defined in its 'ShowErrorComponent' instance; we perform additional -- adjustments in 'customErrorBundlePretty'. -instance ShowErrorComponent CustomErr where +instance ShowErrorComponent HledgerParseErrorData where showErrorComponent (ErrorFailAt _ _ errMsg) = errMsg showErrorComponent (ErrorReparsing _) = "" -- dummy value @@ -98,7 +103,7 @@ instance ShowErrorComponent CustomErr where -- start of the input stream (the number of tokens processed at that -- point). -parseErrorAt :: Int -> String -> CustomErr +parseErrorAt :: Int -> String -> HledgerParseErrorData parseErrorAt offset = ErrorFailAt offset (offset+1) -- | Fail at a specific source interval, given by the raw offsets of its @@ -112,7 +117,7 @@ parseErrorAtRegion :: Int -- ^ Start offset -> Int -- ^ End end offset -> String -- ^ Error message - -> CustomErr + -> HledgerParseErrorData parseErrorAtRegion startOffset endOffset msg = if startOffset < endOffset then ErrorFailAt startOffset endOffset msg @@ -142,7 +147,7 @@ getExcerptText (SourceExcerpt _ txt) = txt -- This function could be extended to return the result of 'p', but we don't -- currently need this. -excerpt_ :: MonadParsec CustomErr Text m => m a -> m SourceExcerpt +excerpt_ :: MonadParsec HledgerParseErrorData Text m => m a -> m SourceExcerpt excerpt_ p = do offset <- getOffset (!txt, _) <- match p @@ -164,8 +169,8 @@ excerpt_ p = do reparseExcerpt :: Monad m => SourceExcerpt - -> ParsecT CustomErr Text m a - -> ParsecT CustomErr Text m a + -> ParsecT HledgerParseErrorData Text m a + -> ParsecT HledgerParseErrorData Text m a reparseExcerpt (SourceExcerpt offset txt) p = do (_, res) <- lift $ runParserT' p (offsetInitialState offset txt) case res of @@ -210,7 +215,7 @@ reparseExcerpt (SourceExcerpt offset txt) p = do -- 0 (that is, the beginning of the source file), which is the -- case for 'ParseErrorBundle's returned from 'runParserT'. -customErrorBundlePretty :: ParseErrorBundle Text CustomErr -> String +customErrorBundlePretty :: HledgerParseErrors -> String customErrorBundlePretty errBundle = let errBundle' = errBundle { bundleErrors = NE.sortWith errorOffset $ -- megaparsec requires that the list of errors be sorted by their offsets @@ -219,7 +224,7 @@ customErrorBundlePretty errBundle = where finalizeCustomError - :: ParseError Text CustomErr -> NE.NonEmpty (ParseError Text CustomErr) + :: ParseError Text HledgerParseErrorData -> NE.NonEmpty (ParseError Text HledgerParseErrorData) finalizeCustomError err = case findCustomError err of Nothing -> pure err @@ -233,7 +238,7 @@ customErrorBundlePretty errBundle = -- If any custom errors are present, arbitrarily take the first one -- (since only one custom error should be used at a time). - findCustomError :: ParseError Text CustomErr -> Maybe CustomErr + findCustomError :: ParseError Text HledgerParseErrorData -> Maybe HledgerParseErrorData findCustomError err = case err of FancyError _ errSet -> finds (\case {ErrorCustom e -> Just e; _ -> Nothing}) errSet @@ -280,7 +285,7 @@ data FinalParseError' e | FinalBundleWithStack (FinalParseErrorBundle' e) deriving (Show) -type FinalParseError = FinalParseError' CustomErr +type FinalParseError = FinalParseError' HledgerParseErrorData -- We need a 'Monoid' instance for 'FinalParseError' so that 'ExceptT -- FinalParseError m' is an instance of Alternative and MonadPlus, which @@ -308,7 +313,7 @@ data FinalParseErrorBundle' e = FinalParseErrorBundle' , includeFileStack :: [FilePath] } deriving (Show) -type FinalParseErrorBundle = FinalParseErrorBundle' CustomErr +type FinalParseErrorBundle = FinalParseErrorBundle' HledgerParseErrorData --- * Constructing and throwing final parse errors @@ -347,7 +352,7 @@ finalCustomFailure = finalFancyFailure . S.singleton . ErrorCustom -- 'attachSource' must be used on a "final" parse error before it can be -- pretty-printed. -finalErrorBundlePretty :: FinalParseErrorBundle' CustomErr -> String +finalErrorBundlePretty :: FinalParseErrorBundle' HledgerParseErrorData -> String finalErrorBundlePretty bundle = concatMap showIncludeFilepath (includeFileStack bundle) <> customErrorBundlePretty (finalErrorBundle bundle) @@ -391,11 +396,11 @@ attachSource filePath sourceText finalParseError = case finalParseError of parseIncludeFile :: Monad m - => StateT st (ParsecT CustomErr Text (ExceptT FinalParseError m)) a + => StateT st (ParsecT HledgerParseErrorData Text (ExceptT FinalParseError m)) a -> st -> FilePath -> Text - -> StateT st (ParsecT CustomErr Text (ExceptT FinalParseError m)) a + -> StateT st (ParsecT HledgerParseErrorData Text (ExceptT FinalParseError m)) a parseIncludeFile parser initialState filepath text = catchError parser' handler where diff --git a/hledger/Hledger/Cli/Commands/Check.hs b/hledger/Hledger/Cli/Commands/Check.hs index 4d5d1adae..b102c73e2 100644 --- a/hledger/Hledger/Cli/Commands/Check.hs +++ b/hledger/Hledger/Cli/Commands/Check.hs @@ -14,8 +14,6 @@ import Data.Either (partitionEithers) import Data.List (isPrefixOf, find) import Control.Monad (forM_) import System.Console.CmdArgs.Explicit -import System.Exit (exitFailure) -import System.IO (stderr, hPutStrLn) import Hledger import Hledger.Cli.CliOptions @@ -120,4 +118,4 @@ runCheck copts@CliOpts{rawopts_} j (check,args) = do case results of Right () -> return () - Left err -> hPutStrLn stderr ("Error: "++err) >> exitFailure + Left err -> error' err diff --git a/hledger/Hledger/Cli/Commands/Check/Ordereddates.hs b/hledger/Hledger/Cli/Commands/Check/Ordereddates.hs index 117fb4780..01d85c525 100755 --- a/hledger/Hledger/Cli/Commands/Check/Ordereddates.hs +++ b/hledger/Hledger/Cli/Commands/Check/Ordereddates.hs @@ -3,11 +3,12 @@ module Hledger.Cli.Commands.Check.Ordereddates ( ) where -import qualified Data.Text as T import Hledger import Hledger.Cli.CliOptions import Control.Monad (forM) import Data.List (groupBy) +import Text.Printf (printf) +import Data.Maybe (fromMaybe) journalCheckOrdereddates :: CliOpts -> Journal -> Either String () journalCheckOrdereddates CliOpts{reportspec_=rspec} j = do @@ -26,17 +27,17 @@ journalCheckOrdereddates CliOpts{reportspec_=rspec} j = do case checkTransactions compare ts of FoldAcc{fa_previous=Nothing} -> Right () FoldAcc{fa_error=Nothing} -> Right () - FoldAcc{fa_error=Just error, fa_previous=Just previous} -> do - let - datestr = if date2_ ropts then "2" else "" - uniquestr = if checkunique then " and/or not unique" else "" - positionstr = sourcePosPairPretty $ tsourcepos error - txn1str = T.unpack . linesPrepend (T.pack " ") $ showTransaction previous - txn2str = T.unpack . linesPrepend2 (T.pack "> ") (T.pack " ") $ showTransaction error - Left $ - "transaction date" <> datestr <> " is out of order" - <> uniquestr <> "\nat " <> positionstr <> ":\n\n" - <> txn1str <> txn2str + FoldAcc{fa_error=Just t, fa_previous=Just tprev} -> Left $ printf + "%s:%d:%d-%d:\n%stransaction date%s is out of order with previous transaction date %s%s" + f l col col2 ex datenum tprevdate oruniquestr + where + (f,l,mcols,ex) = makeTransactionErrorExcerpt t finderrcols + col = maybe 0 fst mcols + col2 = maybe 0 (fromMaybe 0 . snd) mcols + finderrcols _t = Just (1, Just 10) + datenum = if date2_ ropts then "2" else "" + tprevdate = show $ (if date2_ ropts then transactionDate2 else tdate) tprev + oruniquestr = if checkunique then ", and/or not unique" else "" -- XXX still used ? data FoldAcc a b = FoldAcc { fa_error :: Maybe a diff --git a/hledger/Hledger/Cli/Commands/Check/Uniqueleafnames.hs b/hledger/Hledger/Cli/Commands/Check/Uniqueleafnames.hs index 8e66a3641..66baace69 100755 --- a/hledger/Hledger/Cli/Commands/Check/Uniqueleafnames.hs +++ b/hledger/Hledger/Cli/Commands/Check/Uniqueleafnames.hs @@ -12,6 +12,7 @@ import Data.Text (Text) import qualified Data.Text as T import Hledger import Text.Printf (printf) +import Data.Maybe (fromMaybe) -- | Check that all the journal's postings are to accounts with a unique leaf name. -- Otherwise, return an error message for the first offending posting. @@ -38,16 +39,22 @@ journalLeafAndFullAccountNames = map leafAndAccountName . journalAccountNamesUse where leafAndAccountName a = (accountLeafName a, a) checkposting :: [(Text,[AccountName])] -> Posting -> Either String () -checkposting leafandfullnames Posting{paccount,ptransaction} = - case [lf | lf@(_,fs) <- leafandfullnames, paccount `elem` fs] of +checkposting leafandfullnames p@Posting{paccount=a} = + case [lf | lf@(_,fs) <- leafandfullnames, a `elem` fs] of [] -> Right () (leaf,fulls):_ -> Left $ printf - "account leaf names are not unique\nleaf name \"%s\" appears in account names: %s%s" - leaf - (T.intercalate ", " $ map (("\""<>).(<>"\"")) fulls) - (case ptransaction of - Nothing -> "" - Just t -> printf "\nseen in \"%s\" in transaction at: %s\n\n%s" - paccount - (sourcePosPairPretty $ tsourcepos t) - (linesPrepend "> " . (<>"\n") . textChomp $ showTransaction t) :: String) + "%s:%d:%d-%d:\n%saccount leaf name \"%s\" is not unique\nit is used in account names: %s" + f l col col2 ex leaf accts + where + -- t = fromMaybe nulltransaction ptransaction -- XXX sloppy + col = maybe 0 fst mcols + col2 = maybe 0 (fromMaybe 0 . snd) mcols + (f,l,mcols,ex) = makePostingErrorExcerpt p finderrcols + where + finderrcols p _ _ = Just (col, Just col2) + where + alen = T.length $ paccount p + llen = T.length $ accountLeafName a + col = 5 + (if isVirtual p then 1 else 0) + alen - llen + col2 = col + llen - 1 + accts = T.intercalate ", " $ map (("\""<>).(<>"\"")) fulls diff --git a/hledger/test/check-uniqueleafnames.test b/hledger/test/check-uniqueleafnames.test index 41d6cc373..1165f77e2 100644 --- a/hledger/test/check-uniqueleafnames.test +++ b/hledger/test/check-uniqueleafnames.test @@ -11,5 +11,5 @@ $ hledger -f- check uniqueleafnames (a) 1 (b:a) 1 $ hledger -f- check uniqueleafnames ->2 /account leaf names are not unique/ +>2 /account leaf name .* is not unique/ >=1 diff --git a/hledger/test/csv.test b/hledger/test/csv.test index b258d4f0d..7da5f240d 100644 --- a/hledger/test/csv.test +++ b/hledger/test/csv.test @@ -774,7 +774,7 @@ if|account2|comment %description Flubber|acct| $ ./csvtest.sh >2 -hledger: input.rules:6:1: +hledger: Error: input.rules:6:1: | 6 | %amount 150|acct2 | ^ @@ -796,7 +796,7 @@ account2 acct comment cmt $ ./csvtest.sh >2 -hledger: input.rules:5:1: +hledger: Error: input.rules:5:1: | 5 | if Flubber | ^ @@ -822,7 +822,7 @@ if Flubber account2 %myaccount2 $ ./csvtest.sh >2 -hledger: input.rules:6:3: +hledger: Error: input.rules:6:3: | 6 | myaccount2 acct | ^^^^^^^^^^^^ @@ -870,7 +870,7 @@ if account2 comment %description Flubber acct $ ./csvtest.sh >2 -hledger: input.rules:5:1: +hledger: Error: input.rules:5:1: | 5 | if account2 comment | ^ diff --git a/hledger/test/errors/Makefile b/hledger/test/errors/Makefile new file mode 100644 index 000000000..3e6cc6e2c --- /dev/null +++ b/hledger/test/errors/Makefile @@ -0,0 +1,44 @@ +# Check error messages of hledger in $PATH against current error tests. +test: + @printf "Running error message tests with hledger $$(hledger --version | awk '{print $$2}'):\n" + shelltest *.test + +TESTJOURNALS=*.j + +# Update error message tests and readme based on the latest test journals +# and error output of hledger in $PATH. +update: tests readme + +tests: + @printf "Updating *.test with the error messages of hledger $$(hledger --version | awk '{print $$2}')\n" + @read -p "ok ? Press enter: " + for f in $(TESTJOURNALS); do make -s $$(basename $$f .j).test; done + make -s test + +# Generate a shelltest. Run the test script/journal to generate the error message. +# Since the error will contain an absolute file path, we must: +# 1. remove most of the file path +# 2. test with a (multiline) regex rather than literal text +# 3. backslash-quote most forward slashes in error messages +# 4. neutralise any remaining problematic error text (eg in parseable-regexps.test) +%.test: %.j + head -1 $< | sed -E 's%#!/usr/bin/env -S (.*)%$$$$$$ \1 $<%' >$@ + printf ">>>2 /" >>$@ + -./$< 2>&1 | sed -E \ + -e 's%(hledger: Error: ).*/\./(.*)%\1.*\2%' \ + -e 's%/%\\/%g' \ + -e 's%alias \\/\(\\/%alias \\/\\(\\/%' \ + -e 's%compiled: \(%compiled: \\(%' \ + >>$@ + printf "/\n>>>= 1" >>$@ + +readme: $(TESTJOURNALS) + @printf "Updating README.md with the error messages of hledger $$(hledger --version | awk '{print $$2}')\n" + @read -p "ok ? Press enter: " + sed '//q' README.md.tmp + echo "$$(hledger --version | cut -d, -f1) error messages:" >>README.md.tmp + for f in $(TESTJOURNALS); do \ + printf '\n### %s\n```\n%s\n```\n\n' "$$(basename "$$f" .j)" "$$(./"$$f" 2>&1)"; \ + done >>README.md.tmp + mv README.md.tmp README.md + diff --git a/hledger/test/errors/README.md b/hledger/test/errors/README.md index f6e96c136..0a2e2b65c 100644 --- a/hledger/test/errors/README.md +++ b/hledger/test/errors/README.md @@ -20,36 +20,6 @@ Some files contain extra declarations to ease flycheck testing. [flycheck-hledger-10]: https://github.com/DamienCassou/flycheck-hledger/pull/10 [#1436]: https://github.com/simonmichael/hledger/issues/1436 -## Status - -Here is the current status -(hledger 1.25, flycheck 87b275b9): - -| | format | accurate line(s) | accurate column(s) | visual | flycheck detects | flycheck region | -|--------------------------|---------|------------------|--------------------|--------|------------------|-----------------| -| parseable | format1 | Y | Y | YY | Y | Y | -| parseable-dates | format1 | Y | Y | YY | Y | Y | -| parseable-regexps | format1 | Y | Y | YY | Y | Y | -| balanced | | Y | - | Y | Y | | -| balancednoautoconversion | | Y | - | Y | Y | | -| assertions | | Y | | Y | Y | Y | -| accounts | format2 | | | Y | Y | | -| commodities | format2 | | | Y | Y | | -| payees | format2 | | | Y | Y | Y | -| ordereddates | format2 | | | Y | Y | Y | -| uniqueleafnames | | | | Y | Y | | - -Key: -- format: the error message follows a standard format - (format1: location on first line, megaparsec-like. - format2: summary on first line, location on second line, rustc-like. - std: new standard format) -- accurate line - the optimal line(s) is(are) selected -- accurate column - the optimal column(s) is(are) selected -- visual - the CLI error message shows a relevant excerpt (Y), ideally with the error highlighted (YY) -- flycheck detects - flycheck recognises the error output, reports the error and doesn't give a "suspicious" warning -- flycheck region - flycheck highlights a reasonably accurate text region containing the error - ## Goals - [x] phase 1: update flycheck to detect journal errors of current hledger release (and keep a branch updated to detect errors of latest hledger master) @@ -67,67 +37,84 @@ Key: - [x] phase 13: decide/add error ids/explanations/web pages ? not needed - [ ] phase 14: support Language Server Protocol & Visual Code +## Current status + +Here is the current status +(hledger 1.25.99-gd278c4c71-20220422, flycheck 87b275b9): + +| | std format | line | column | excerpt | flycheck | flycheck region | +|--------------------------|------------|------|------------|---------|----------|-----------------| +| accounts | Y | Y | Y | YY | | | +| assertions | | Y | | Y | | | +| balanced | | Y | - | Y | | | +| balancednoautoconversion | | Y | - | Y | | | +| commodities | Y | Y | Y (approx) | YY | | | +| ordereddates | Y | Y | Y | YY | | | +| parseable | Y | Y | Y | YY | | | +| parseable-dates | Y | Y | Y | YY | | | +| parseable-regexps | Y | Y | Y | YY | | | +| payees | Y | Y | Y | YY | | | +| uniqueleafnames | Y | Y | Y | YY | | | + +Key: +- std format - the error message follows a standard format (location on first line, megaparsec-like excerpt, description). +- line - the optimal line(s) is(are) selected +- column - the optimal column(s) is(are) selected +- excerpt - a useful excerpt is shown (Y), ideally with the error highlighted (YY) +- flycheck - latest flycheck release recognises and reports the error, with no "suspicious state" warning +- flycheck region - flycheck highlights a reasonably accurate region containing the error + +## Preferred error format + +Here is our preferred error message layout for now: +``` +hledger: Error: FILE:LOCATION: +EXCERPT +SUMMARY +[DETAILS] +``` + +Notes (see also [#1436][]): + +- the "hledger: " prefix could be dropped later with a bit more effort +- includes the word "Error" and the error position on line 1 +- FILE is the file path +- LOCATION is `LINE[-ENDLINE][:COLUMN[-ENDCOLUMN]]` +- we may show 0 for LINE or COLUMN when unknown +- EXCERPT is a short visual snippet whenever possible, with the error region highlighted, line numbers, and colour when supported. This section must be easy for flycheck to ignore. +- SUMMARY is a one line description/explanation of the problem. + These are currently dynamic, they can include helpful contextual info. + ShellCheck uses static summaries. +- DETAILS is optional additional details/advice when needed. +- this layout is based on megaparsec's +- for comparison: rustc puts summary on line 1 and location on line 2: + ``` + Error[ID]: SUMMARY + at FILE:LOCATION + EXCERPT + [DETAILS] + ``` +- try https://github.com/mesabloo/diagnose later + ## Current journal errors -hledger 1.25.99-g133c54434-20220414 error messages, last updated 2022-04-15: + +hledger 1.25.99-g9bff671b5-20220424 error messages: -### parseable +### accounts ``` -hledger: /Users/simon/src/hledger/hledger/test/errors/./parseable.j:3:2: - | -3 | 1 - | ^ -unexpected newline -expecting date separator or digit - +hledger: Error: /Users/simon/src/hledger/hledger/test/errors/./accounts.j:4:6-6: + | 2022-01-01 +4 | (a) 1 + | ^ +undeclared account "a" ``` -### parseable-dates -``` -hledger: /Users/simon/src/hledger/hledger/test/errors/./parseable-dates.j:3:1: - | -3 | 2022/1/32 - | ^^^^^^^^^ -well-formed but invalid date: 2022/1/32 - -``` - -### parseable-regexps -``` -hledger: /Users/simon/src/hledger/hledger/test/errors/./parseable-regexps.j:3:8: - | -3 | alias /(/ = a - | ^ -this regular expression could not be compiled: ( - -``` - -### balanced -``` -hledger: /Users/simon/src/hledger/hledger/test/errors/./balanced.j:3-4 -could not balance this transaction: -real postings' sum should be 0 but is: 1 -2022-01-01 - a 1 - -``` - -### balancednoautoconversion -``` -hledger: /Users/simon/src/hledger/hledger/test/errors/./balancednoautoconversion.j:6-8 -could not balance this transaction: -real postings' sum should be 0 but is: 1 A --1 B -2022-01-01 - a 1 A - b -1 B - -``` ### assertions ``` -hledger: balance assertion: /Users/simon/src/hledger/hledger/test/errors/./assertions.j:4:8 +hledger: Error: balance assertion: /Users/simon/src/hledger/hledger/test/errors/./assertions.j:4:8 transaction: 2022-01-01 a 0 = 1 @@ -139,112 +126,99 @@ commodity: calculated: 0 asserted: 1 difference: 1 - ``` -### accounts -``` -Error: undeclared account "a" -in transaction at: /Users/simon/src/hledger/hledger/test/errors/./accounts.j:3-4 - - 2022-01-01 - (a) 1 +### balanced ``` +hledger: Error: /Users/simon/src/hledger/hledger/test/errors/./balanced.j:3-4 +could not balance this transaction: +real postings' sum should be 0 but is: 1 +2022-01-01 + a 1 +``` + + +### balancednoautoconversion +``` +hledger: Error: /Users/simon/src/hledger/hledger/test/errors/./balancednoautoconversion.j:6-8 +could not balance this transaction: +real postings' sum should be 0 but is: 1 A +-1 B +2022-01-01 + a 1 A + b -1 B +``` + ### commodities ``` -Error: undeclared commodity "A" -in transaction at: /Users/simon/src/hledger/hledger/test/errors/./commodities.j:5-6 - - 2022-01-01 - (a) A 1 - +hledger: Error: /Users/simon/src/hledger/hledger/test/errors/./commodities.j:6:21-23: + | 2022-01-01 +6 | (a) A 1 + | ^^^ +undeclared commodity "A" ``` -### payees -``` -Error: undeclared payee "p" -at: /Users/simon/src/hledger/hledger/test/errors/./payees.j:6-7 - -> 2022-01-01 p - (a) A 1 - -``` ### ordereddates ``` -Error: transaction date is out of order -at /Users/simon/src/hledger/hledger/test/errors/./ordereddates.j:10-11: - - 2022-01-02 p - (a) 1 - -> 2022-01-01 p - (a) 1 - - +hledger: Error: /Users/simon/src/hledger/hledger/test/errors/./ordereddates.j:10:1-10: +10 | 2022-01-01 p + | ^^^^^^^^^^ + | (a) 1 +transaction date is out of order with previous transaction date 2022-01-02 ``` + +### parseable-dates +``` +hledger: Error: /Users/simon/src/hledger/hledger/test/errors/./parseable-dates.j:3:1: + | +3 | 2022/1/32 + | ^^^^^^^^^ +well-formed but invalid date: 2022/1/32 +``` + + +### parseable-regexps +``` +hledger: Error: /Users/simon/src/hledger/hledger/test/errors/./parseable-regexps.j:3:8: + | +3 | alias /(/ = a + | ^ +this regular expression could not be compiled: ( +``` + + +### parseable +``` +hledger: Error: /Users/simon/src/hledger/hledger/test/errors/./parseable.j:3:2: + | +3 | 1 + | ^ +unexpected newline +expecting date separator or digit +``` + + +### payees +``` +hledger: Error: /Users/simon/src/hledger/hledger/test/errors/./payees.j:6:12-12: +6 | 2022-01-01 p + | ^ + | (a) A 1 +undeclared payee "p" +``` + + ### uniqueleafnames ``` -Error: account leaf names are not unique -leaf name "c" appears in account names: "a:c", "b:c" -seen in "a:c" in transaction at: /Users/simon/src/hledger/hledger/test/errors/./uniqueleafnames.j:8-9 - -> 2022-01-01 p -> (a:c) 1 - +hledger: Error: /Users/simon/src/hledger/hledger/test/errors/./uniqueleafnames.j:9:8-8: + | 2022-01-01 p +9 | (a:c) 1 + | ^ +account leaf name "c" is not unique +it is used in account names: "a:c", "b:c" ``` - -## New error format - -The preferred standard error format for now is the following, -similar to the one megaparsec gives us -and probably the easiest to implement consistently: - -``` -Error: FILE:LOCATION: -EXCERPT -SUMMARY -[DETAILS] -``` - -Other format notes (see also [#1436][]): - -megaparsec-like: - -``` -Error: [ID] FILE:LOCATION -EXCERPT -SUMMARY -[DETAILS] -``` - -- begins with the word "Error" -- ID is an optional error id, eg `HL1001` (in brackets ?). We might adopt these, similar to ShellCheck. -- FILE is the file path. -- LOCATION is `LINE[-ENDLINE][:COLUMN[-ENDCOLUMN]]`. Having location on the first line helps some tools, like Emacs M-x compile. -- EXCERPT is a short visual snippet whenever possible, with the error region highlighted, line numbers, and colour when supported. This section must be easy for flycheck to ignore. -- SUMMARY is a one line description/explanation of the problem. Currently we use dynamic summaries including contextual data for clarity. ShellCheck uses static summaries, which might have some advantages. -- DETAILS is optional additional details/advice when needed. - -rustc-like: - -``` -Error[ID]: SUMMARY -at FILE:LOCATION -EXCERPT -[DETAILS] -``` - -- Having summary on the first line can be helpful eg when grepping logged errors. - -Questions: - -- location needed on first line for maximum tool support ? -- summary needed on first line for maximum concision/greppability ? -- allow long, much-wider-than-80-char first lines or not ? -- dynamic or static summary ? -- error ids/explanations needed ? local and/or web based ? easily editable ? document old hledger versions ? diff --git a/hledger/test/errors/accounts.test b/hledger/test/errors/accounts.test new file mode 100644 index 000000000..79c152046 --- /dev/null +++ b/hledger/test/errors/accounts.test @@ -0,0 +1,9 @@ +$$$ hledger check accounts -f accounts.j +>>>2 /hledger: Error: .*accounts.j:4:6-6: + | 2022-01-01 +4 | (a) 1 + | ^ +undeclared account "a" + +/ +>>>= 1 \ No newline at end of file diff --git a/hledger/test/errors/assertions.test b/hledger/test/errors/assertions.test new file mode 100644 index 000000000..0e2a6d462 --- /dev/null +++ b/hledger/test/errors/assertions.test @@ -0,0 +1,16 @@ +$$$ hledger check -f assertions.j +>>>2 /hledger: Error: .*assertions.j:4:8 +transaction: +2022-01-01 + a 0 = 1 + +assertion details: +date: 2022-01-01 +account: a +commodity: +calculated: 0 +asserted: 1 +difference: 1 + +/ +>>>= 1 \ No newline at end of file diff --git a/hledger/test/errors/balanced.test b/hledger/test/errors/balanced.test new file mode 100644 index 000000000..0ac26e501 --- /dev/null +++ b/hledger/test/errors/balanced.test @@ -0,0 +1,9 @@ +$$$ hledger check -f balanced.j +>>>2 /hledger: Error: .*balanced.j:3-4 +could not balance this transaction: +real postings' sum should be 0 but is: 1 +2022-01-01 + a 1 + +/ +>>>= 1 \ No newline at end of file diff --git a/hledger/test/errors/balancednoautoconversion.j b/hledger/test/errors/balancednoautoconversion.j new file mode 100755 index 000000000..2189e1edc --- /dev/null +++ b/hledger/test/errors/balancednoautoconversion.j @@ -0,0 +1,8 @@ +#!/usr/bin/env -S hledger check balancednoautoconversion -f +# Show the error when balancedwithautoconversion is required +# and an implicit commodity conversion is found. +# Currently the same as the regular balancedwithautoconversion error. + +1/1 + a 1 A + b -1 B diff --git a/hledger/test/errors/balancednoautoconversion.test b/hledger/test/errors/balancednoautoconversion.test new file mode 100644 index 000000000..69ad32145 --- /dev/null +++ b/hledger/test/errors/balancednoautoconversion.test @@ -0,0 +1,11 @@ +$$$ hledger check balancednoautoconversion -f balancednoautoconversion.j +>>>2 /hledger: Error: .*balancednoautoconversion.j:6-8 +could not balance this transaction: +real postings' sum should be 0 but is: 1 A +-1 B +2022-01-01 + a 1 A + b -1 B + +/ +>>>= 1 \ No newline at end of file diff --git a/hledger/test/errors/commodities.test b/hledger/test/errors/commodities.test new file mode 100644 index 000000000..6ecd7c466 --- /dev/null +++ b/hledger/test/errors/commodities.test @@ -0,0 +1,9 @@ +$$$ hledger check commodities -f commodities.j +>>>2 /hledger: Error: .*commodities.j:6:21-23: + | 2022-01-01 +6 | (a) A 1 + | ^^^ +undeclared commodity "A" + +/ +>>>= 1 \ No newline at end of file diff --git a/hledger/test/errors/ordereddates.test b/hledger/test/errors/ordereddates.test new file mode 100644 index 000000000..7cc02a644 --- /dev/null +++ b/hledger/test/errors/ordereddates.test @@ -0,0 +1,8 @@ +$$$ hledger check ordereddates -f ordereddates.j +>>>2 /hledger: Error: .*ordereddates.j:10:1-10: +10 | 2022-01-01 p + | ^^^^^^^^^^ + | (a) 1 +transaction date is out of order with previous transaction date 2022-01-02 +/ +>>>= 1 \ No newline at end of file diff --git a/hledger/test/errors/parseable-dates.test b/hledger/test/errors/parseable-dates.test new file mode 100644 index 000000000..b08e652f9 --- /dev/null +++ b/hledger/test/errors/parseable-dates.test @@ -0,0 +1,9 @@ +$$$ hledger check -f parseable-dates.j +>>>2 /hledger: Error: .*parseable-dates.j:3:1: + | +3 | 2022\/1\/32 + | ^^^^^^^^^ +well-formed but invalid date: 2022\/1\/32 + +/ +>>>= 1 \ No newline at end of file diff --git a/hledger/test/errors/parseable-regexps.test b/hledger/test/errors/parseable-regexps.test new file mode 100644 index 000000000..958e5a8b3 --- /dev/null +++ b/hledger/test/errors/parseable-regexps.test @@ -0,0 +1,9 @@ +$$$ hledger check -f parseable-regexps.j +>>>2 /hledger: Error: .*parseable-regexps.j:3:8: + | +3 | alias \/\(\/ = a + | ^ +this regular expression could not be compiled: \( + +/ +>>>= 1 \ No newline at end of file diff --git a/hledger/test/errors/parseable.test b/hledger/test/errors/parseable.test new file mode 100644 index 000000000..e99de9e52 --- /dev/null +++ b/hledger/test/errors/parseable.test @@ -0,0 +1,10 @@ +$$$ hledger check -f parseable.j +>>>2 /hledger: Error: .*parseable.j:3:2: + | +3 | 1 + | ^ +unexpected newline +expecting date separator or digit + +/ +>>>= 1 \ No newline at end of file diff --git a/hledger/test/errors/payees.test b/hledger/test/errors/payees.test new file mode 100644 index 000000000..c46874bdc --- /dev/null +++ b/hledger/test/errors/payees.test @@ -0,0 +1,9 @@ +$$$ hledger check payees -f payees.j +>>>2 /hledger: Error: .*payees.j:6:12-12: +6 | 2022-01-01 p + | ^ + | (a) A 1 +undeclared payee "p" + +/ +>>>= 1 \ No newline at end of file diff --git a/hledger/test/errors/showall b/hledger/test/errors/showall deleted file mode 100755 index aa619a12b..000000000 --- a/hledger/test/errors/showall +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env sh -# Execute all test journals, showing their error messages -# (as README-ready markdown). - -# All test journals in this directory, in preferred test/display order -testfiles="\ - parseable.j \ - parseable-dates.j \ - parseable-regexps.j \ - balanced.j \ - balancednoautoconversion.j \ - assertions.j \ - accounts.j \ - commodities.j \ - payees.j \ - ordereddates.j \ - uniqueleafnames.j \ - " - -printf '%s error messages, last updated %s:\n\n' \ - "$(hledger --version | cut -d, -f1)" \ - "$(date +%Y-%m-%d)" - -for f in $testfiles; do - printf '### %s\n```\n' "$(echo "$f" | cut -d. -f1)" - ./"$f" || true - printf '```\n\n' -done diff --git a/hledger/test/errors/uniqueleafnames.test b/hledger/test/errors/uniqueleafnames.test new file mode 100644 index 000000000..6934a3e32 --- /dev/null +++ b/hledger/test/errors/uniqueleafnames.test @@ -0,0 +1,9 @@ +$$$ hledger check uniqueleafnames -f uniqueleafnames.j +>>>2 /hledger: Error: .*uniqueleafnames.j:9:8-8: + | 2022-01-01 p +9 | (a:c) 1 + | ^ +account leaf name "c" is not unique +it is used in account names: "a:c", "b:c" +/ +>>>= 1 \ No newline at end of file diff --git a/hledger/test/forecast.test b/hledger/test/forecast.test index 09a9a1e3c..3307d070e 100644 --- a/hledger/test/forecast.test +++ b/hledger/test/forecast.test @@ -172,7 +172,7 @@ Balance changes in 2016-10-01..2017-01-31: $ hledger bal -M -b 2016-10 -e 2017-02 -f - --forecast=20160801-foobar > >2 -hledger: could not parse forecast period : 1:10: +hledger: Error: could not parse forecast period : 1:10: | 1 | 20160801-foobar | ^ diff --git a/hledger/test/journal/parse-errors.test b/hledger/test/journal/parse-errors.test index 4d0b93df5..57ad081bd 100644 --- a/hledger/test/journal/parse-errors.test +++ b/hledger/test/journal/parse-errors.test @@ -8,7 +8,7 @@ # 1. $ hledger -f - print >2 -hledger: -:1:5: +hledger: Error: -:1:5: | 1 | 2018 | ^ @@ -123,7 +123,7 @@ $ hledger -f- print b 1B $ hledger -f- print >2 -hledger: -:1-3 +hledger: Error: -:1-3 could not balance this transaction: real postings all have the same sign 2020-01-01 diff --git a/hledger/test/roi.test b/hledger/test/roi.test index dfc1dcfdd..d360f06d0 100644 --- a/hledger/test/roi.test +++ b/hledger/test/roi.test @@ -238,7 +238,7 @@ hledger -f- roi -p 2019-11 --inv Investment --pnl PnL Assets:Checking 101 A Unrealized PnL >>>2 -hledger: Amounts could not be converted to a single cost basis: ["10 B","-10 B @@ 100 A"] +hledger: Error: Amounts could not be converted to a single cost basis: ["10 B","-10 B @@ 100 A"] Consider using --value to force all costs to be in a single commodity. For example, "--cost --value=end, --infer-market-prices", where commodity is the one that was used to pay for the investment. >>>=1