Skip to content

Commit 4056d08

Browse files
authored
New String function: parseErrorHuman (#209)
1 parent f59a383 commit 4056d08

File tree

4 files changed

+143
-5
lines changed

4 files changed

+143
-5
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Breaking changes:
1111
New features:
1212

1313
- add `MonadAsk` and `MonadReader` instances (#208 by @bentongxyz)
14+
- Add `Parsing.String.parseErrorHuman` (#209 by @jamesdbrock)
1415

1516
Other improvements:
1617

src/Parsing.purs

+2
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,11 @@ import Data.Tuple (Tuple(..), fst)
5151
-- | the position in the input stream at which the error occurred.
5252
data ParseError = ParseError String Position
5353

54+
-- | Get the `Message` from a `ParseError`
5455
parseErrorMessage :: ParseError -> String
5556
parseErrorMessage (ParseError msg _) = msg
5657

58+
-- | Get the `Position` from a `ParseError`.
5759
parseErrorPosition :: ParseError -> Position
5860
parseErrorPosition (ParseError _ pos) = pos
5961

src/Parsing/String.purs

+101-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
-- | Primitive parsers for working with an input stream of type `String`.
1+
-- | Primitive parsers, combinators and functions for working with an input
2+
-- | stream of type `String`.
23
-- |
34
-- | All of these primitive parsers will consume when they succeed.
45
-- |
@@ -47,17 +48,21 @@ module Parsing.String
4748
, regex
4849
, anyTill
4950
, consumeWith
51+
, parseErrorHuman
5052
) where
5153

5254
import Prelude hiding (between)
5355

5456
import Control.Monad.Rec.Class (Step(..), tailRecM)
57+
import Data.Array (replicate)
5558
import Data.Array.NonEmpty as NonEmptyArray
5659
import Data.Either (Either(..))
5760
import Data.Enum (fromEnum, toEnum)
5861
import Data.Function.Uncurried (mkFn5, runFn2)
62+
import Data.Int (odd)
5963
import Data.Maybe (Maybe(..), fromJust)
6064
import Data.String (CodePoint, Pattern(..), codePointAt, length, null, splitAt, stripPrefix, uncons)
65+
import Data.String as CodePoint
6166
import Data.String as String
6267
import Data.String.CodeUnits as SCU
6368
import Data.String.Regex as Regex
@@ -339,4 +344,98 @@ anyTill p = do
339344
( do
340345
_ <- anyCodePoint
341346
pure $ Loop unit
342-
)
347+
)
348+
349+
-- | Returns three `String`s which, when printed line-by-line, will show
350+
-- | a human-readable parsing error message with context.
351+
-- |
352+
-- | #### Input arguments
353+
-- |
354+
-- | * The first argument is the input `String` given to the parser which
355+
-- | errored.
356+
-- | * The second argument is a positive `Int` which indicates how many
357+
-- | characters of input `String` context are wanted around the parsing error.
358+
-- | * The third argument is the `ParseError` for the input `String`.
359+
-- |
360+
-- | #### Output `String`s
361+
-- |
362+
-- | 1. The parse error message and the parsing position.
363+
-- | 2. A string with an arrow that points to the error position in the
364+
-- | input context (in a fixed-width font).
365+
-- | 3. The input context. A substring of the input which tries to center
366+
-- | the error position and have the wanted length and not include
367+
-- | any newlines or carriage returns.
368+
-- |
369+
-- | If the parse error occurred on a carriage return or newline character,
370+
-- | then that character will be included at the end of the input context.
371+
-- |
372+
-- | #### Example
373+
-- |
374+
-- | ```
375+
-- | let input = "12345six789"
376+
-- | case runParser input (replicateA 9 String.Basic.digit) of
377+
-- | Left err ->
378+
-- | log $ String.joinWith "\n" $ parseErrorHuman input 20 err
379+
-- | ```
380+
-- | ---
381+
-- | ```
382+
-- | Expected digit at position index:5 (line:1, column:6)
383+
-- | ▼
384+
-- | 12345six789
385+
-- | ```
386+
parseErrorHuman :: String -> Int -> ParseError -> Array String
387+
parseErrorHuman input contextSize (ParseError msg (Position { line, column, index })) =
388+
-- inspired by
389+
-- https://github.com/elm/parser/blob/master/README.md#tracking-context
390+
[ msg <> " at position index:" <> show index
391+
<> " (line:"
392+
<> show line
393+
<> ", column:"
394+
<> show column
395+
<> ")"
396+
, (String.joinWith "" (replicate (lineIndex - minPosBefore) " ")) <> "" -- best way to construct string of spaces?
397+
, inputContext
398+
]
399+
where
400+
-- select the input line in which the error appears
401+
-- sadly we can't use splitCap because of circular module dependency and we
402+
-- don't feel like separating out an “Internal” module.
403+
{ posBegin, posEnd, lineBegin } = go 0 input 0 input
404+
where
405+
go posBegin lineBegin posEnd lineEnd =
406+
case String.uncons lineEnd of
407+
Just { head, tail } | head == CodePoint.codePointFromChar '\n' ->
408+
if posEnd == index -- uh-oh, error at the newline
409+
-- so include the newline at the end of the selected line.
410+
then { posBegin, posEnd: posEnd + 1, lineBegin }
411+
else if posEnd > index then { posBegin, posEnd, lineBegin }
412+
else go (posEnd + 1) tail (posEnd + 1) tail
413+
Just { head, tail } | head == CodePoint.codePointFromChar '\r' ->
414+
if posEnd == index -- uh-oh, error at the carriage return
415+
-- so include the carriage return at the end of the selected line.
416+
-- we don't need to add the possible following newline because
417+
-- we're not printing a line break here, we're just making sure
418+
-- to include the character at the position which errored.
419+
then { posBegin, posEnd: posEnd + 1, lineBegin }
420+
else if posEnd > index then { posBegin, posEnd, lineBegin }
421+
else go (posEnd + 1) tail (posEnd + 1) tail
422+
Just { tail } -> go posBegin lineBegin (posEnd + 1) tail
423+
_ -> { posBegin, posEnd, lineBegin }
424+
lineSelect = String.take (posEnd - posBegin) lineBegin
425+
lineIndex = index - posBegin
426+
lineLength = String.length lineSelect
427+
428+
-- position minus half of context
429+
bestPosBefore = lineIndex - (contextSize / 2)
430+
-- position plus half of context
431+
bestPosAfter = lineIndex + (contextSize / 2) + if odd contextSize then 1 else 0
432+
433+
-- constrain the context window to selected line
434+
-- grow the context window to contextSize if the error is at beginning or end of selected line
435+
Tuple minPosBefore maxPosAfter =
436+
if bestPosBefore >= 0 then
437+
if bestPosAfter <= lineLength then Tuple bestPosBefore bestPosAfter
438+
else Tuple (max 0 (lineLength - contextSize)) lineLength
439+
else Tuple 0 (min lineLength contextSize)
440+
441+
inputContext = String.take (maxPosAfter - minPosBefore) $ String.drop minPosBefore lineSelect

test/Main.purs

+39-3
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import Control.Monad.State (State, lift, modify, runState)
1313
import Data.Array (some, toUnfoldable)
1414
import Data.Array as Array
1515
import Data.Bifunctor (lmap, rmap)
16-
import Data.Either (Either(..), either, hush)
16+
import Data.Either (Either(..), either, fromLeft, hush)
1717
import Data.Foldable (oneOf)
1818
import Data.List (List(..), fromFoldable, (:))
1919
import Data.List.NonEmpty (NonEmptyList(..), catMaybes, cons, cons')
@@ -34,12 +34,13 @@ import Effect.Console (log, logShow)
3434
import Effect.Unsafe (unsafePerformEffect)
3535
import Node.Process (lookupEnv)
3636
import Parsing (ParseError(..), ParseState(..), Parser, ParserT, Position(..), consume, fail, getParserT, initialPos, parseErrorMessage, parseErrorPosition, position, region, runParser)
37-
import Parsing.Combinators (advance, between, chainl, chainl1, chainr, chainr1, choice, empty, endBy, endBy1, lookAhead, many, many1, many1Till, many1Till_, manyIndex, manyTill, manyTill_, notFollowedBy, optionMaybe, sepBy, sepBy1, sepEndBy, sepEndBy1, skipMany, skipMany1, try, (<?>), (<??>), (<~?>))
37+
import Parsing.Combinators (advance, between, chainl, chainl1, chainr, chainr1, choice, empty, endBy, endBy1, lookAhead, many, many1, many1Till, many1Till_, manyIndex, manyTill, manyTill_, notFollowedBy, optionMaybe, replicateA, sepBy, sepBy1, sepEndBy, sepEndBy1, skipMany, skipMany1, try, (<?>), (<??>), (<~?>))
3838
import Parsing.Combinators.Array as Combinators.Array
3939
import Parsing.Expr (Assoc(..), Operator(..), buildExprParser)
4040
import Parsing.Language (haskellDef, haskellStyle, javaStyle)
41-
import Parsing.String (anyChar, anyCodePoint, anyTill, char, eof, match, regex, rest, satisfy, string, takeN)
41+
import Parsing.String (anyChar, anyCodePoint, anyTill, char, eof, match, parseErrorHuman, regex, rest, satisfy, string, takeN)
4242
import Parsing.String.Basic (intDecimal, letter, noneOfCodePoints, number, oneOfCodePoints, skipSpaces, whiteSpace)
43+
import Parsing.String.Basic as String.Basic
4344
import Parsing.String.Replace (breakCap, replace, replaceT, splitCap, splitCapT)
4445
import Parsing.Token (TokenParser, makeTokenParser, token, when)
4546
import Parsing.Token as Token
@@ -1070,3 +1071,38 @@ main = do
10701071
{ actual: lmap parseErrorPosition $ runParser "aa" $ advance consume
10711072
, expected: Left (Position { index: 0, line: 1, column: 1 })
10721073
}
1074+
1075+
log "\nTESTS error messages\n"
1076+
do
1077+
let input = "12345six789"
1078+
assertEqual' "parseErrorHuman 1"
1079+
{ actual: Array.drop 1 $ parseErrorHuman input 20 $ fromLeft (ParseError "" initialPos)
1080+
$ runParser input (replicateA 9 String.Basic.digit :: Parser String (List Char))
1081+
, expected: [ "", "12345six789" ]
1082+
}
1083+
1084+
do
1085+
let input = "12345six789"
1086+
assertEqual' "parseErrorHuman 2"
1087+
{ actual: Array.drop 1 $ parseErrorHuman input 5 $ fromLeft (ParseError "" initialPos)
1088+
$ runParser input (replicateA 9 String.Basic.digit :: Parser String (List Char))
1089+
, expected: [ "", "45six" ]
1090+
}
1091+
1092+
do
1093+
let input = "aaaa🍷\r\nbbbb"
1094+
assertEqual' "parseErrorHuman 3"
1095+
{ actual: parseErrorHuman input 20 $ fromLeft (ParseError "" initialPos)
1096+
$ runParser input
1097+
$ string "aaaa" *> (replicateA 7 letter :: Parser String (List Char))
1098+
, expected: [ "Expected letter at position index:4 (line:1, column:5)", "", "aaaa🍷" ]
1099+
}
1100+
1101+
do
1102+
let input = "aaaa\r\n🍷bbbb"
1103+
assertEqual' "parseErrorHuman 4"
1104+
{ actual: parseErrorHuman input 20 $ fromLeft (ParseError "" initialPos)
1105+
$ runParser input
1106+
$ string "aaaa\r\n" *> (replicateA 5 letter :: Parser String (List Char))
1107+
, expected: [ "Expected letter at position index:6 (line:2, column:1)", "", "🍷bbbb" ]
1108+
}

0 commit comments

Comments
 (0)