packages feed

yamlet-1.0.0.0: src/Yamlet/Error.hs

-- | Errors with the position in the input that caused them.
module Yamlet.Error
  ( -- * Errors
    Error (..)
  , Location (..)
  , Path
  , pathElements
  , pathFromElements
  , PathElement (..)
  , prettyError
  , renderPath

    -- * Construction
  , errorAt
  , errorsAt
  , documentErrors
  , locate
  , nodePath
  , nodePaths
  ) where

import Control.DeepSeq
import Data.Char
import Data.List qualified as L
import Data.Map.Strict qualified as M
import Data.Maybe
import Data.Set qualified as Set
import Data.Text qualified as T
import Data.Text.Array qualified as A
import Data.Text.Internal qualified as T
import Data.Text.Unsafe qualified as T
import GHC.Generics

import Yamlet.Internal.Chars
import Yamlet.Internal.Syntax
import Yamlet.Internal.Utils

-- | An error of the parser or the decoder.
--
-- An error from the functions of this module keeps no part of the input
-- alive once it is in weak head normal form. Its message and its path are
-- evaluated then, because a message often contains a slice of the input,
-- e.g. a key, and a path comes from the syntax tree.
data Error = Error
  { location :: !Location
  , message :: !String
  , sourceLine :: !T.Text
  -- ^ The line of the input that contains the location.
  , sourceIndex :: !Int
  -- ^ The index of the location in the UTF-8 bytes of
  -- 'Yamlet.Error.sourceLine'. It lets 'prettyError' find the column without
  -- a scan of the whole line.
  , path :: !Path
  -- ^ The path to the node of a decoder error. An error at a key has the
  -- path of its mapping. The path is empty for an error of the parser and
  -- for a node that a program built.
  }
  deriving stock (Eq, Show, Generic)
  deriving anyclass (NFData)

-- | The keys and the indices from the root of a document to a node.
--
-- The path of a node shares the path of its parent, so the paths of many
-- errors in a deep document take memory linear in its size.
data Path
  = Root
  | Child !Path !PathElement
  deriving stock (Eq)

-- Written by hand, because every field is strict and has no lazy parts, and
-- a generic instance would walk the shared paths of all errors.
instance NFData Path where
  rnf = rwhnf

instance Show Path where
  showsPrec d p =
    showParen (d > 10) $ showString "pathFromElements " . shows (pathElements p)

-- | The steps of a path, from the root.
pathElements :: Path -> [PathElement]
pathElements = go []
  where
    go :: [PathElement] -> Path -> [PathElement]
    go acc = \case
      Root -> acc
      Child p e -> go (e : acc) p

-- | A path with the steps from the root.
pathFromElements :: [PathElement] -> Path
pathFromElements = L.foldl' Child Root

-- | A step of a path into a document.
data PathElement
  = -- | The value of a key that is a scalar, with the text of the key, e.g.
    -- @1@ for the integer key 1.
    Key !T.Text
  | -- | The item of a sequence, from 0.
    Index !Int
  | -- | The value of a key that is a collection, e.g. @? [1, 2]@. The path
    -- has no steps into the key.
    CollectionKey
  | -- | The value of a key that is an alias, with the name of the anchor.
    AliasKey !T.Text
  deriving stock (Eq, Show, Generic)

-- Written by hand, because GHC does not always remove the generic
-- representation of a sum type. Every field is strict and has no lazy parts.
instance NFData PathElement where
  rnf = rwhnf

-- | A position in the input. Lines and columns count from 1, and a column
-- counts characters, not bytes. Line 0 and column 0 mean that the error has
-- no position, e.g. because it comes from a node that a program built, or
-- from a parsed node that is decoded without its input.
data Location = Location
  { offset :: !Offset
  , line :: !Int
  , column :: !Int
  }
  deriving stock (Eq, Show, Generic)
  deriving anyclass (NFData)

-- | Render an error in the format that editors recognize. The result does not
-- end with a line break. If the line is longer than 80 characters, the
-- excerpt shows only the 80 characters around the column. A control
-- character other than a tab shows as its symbol, e.g. U+241B for escape, or
-- as U+FFFD if Unicode has no symbol for it.
--
-- >>> either printErrors print (decodeText @(M.Map T.Text [[Int]]) "jobs:\n  - [1]\n  - 42\n")
-- input.yaml:3:5: jobs[1]: expected a list, but got an integer
--   |
-- 3 |   - 42
--   |     ^
--
-- An error with no position gives only the file and the message, e.g.
-- @input.yaml: duplicate key \"a\"@.
prettyError :: FilePath -> Error -> String
prettyError file err
  | err.location.line == 0 = file ++ ": " ++ message
  | otherwise =
      concat
        [ file
        , ":"
        , show err.location.line
        , ":"
        , show err.location.column
        , ": "
        , message
        , "\n"
        , pad
        , " |\n"
        , lineNo
        , " | "
        , shown
        , "\n"
        , pad
        , " | "
        , caret
        , "^"
        ]
  where
    message :: String
    message
      | err.path == Root = err.message
      | otherwise = renderPath err.path ++ ": " ++ err.message

    lineNo :: String
    lineNo = show err.location.line

    pad :: String
    pad = map (const ' ') lineNo

    -- The usual width of a terminal.
    width :: Int
    width = 80

    -- The characters of the line before and from the location. Each count
    -- stops one past the width, so that a long line takes no longer.
    back, ahead :: Int
    back = fst (stepBack (width + 1))
    ahead = fst (stepAhead (width + 1))

    short :: Bool
    short = back + ahead <= width

    -- The characters of the excerpt before the location.
    inExcerpt :: Int
    inExcerpt = min back (max (width `div` 2) (width - ahead))

    cutBefore, cutAfter :: Bool
    cutBefore = back > inExcerpt
    cutAfter = ahead > width - inExcerpt

    shown :: String
    shown
      | short = map visible (T.unpack err.sourceLine)
      | otherwise =
          (if cutBefore then ellipsis else "")
            ++ map visible (T.unpack (T.Text arr excerptStart (excerptEnd - excerptStart)))
            ++ (if cutAfter then ellipsis else "")
      where
        excerptStart, excerptEnd :: Int
        excerptStart = snd (stepBack inExcerpt)
        excerptEnd = snd (stepAhead (width - inExcerpt))

    -- A terminal would act on a control character, e.g. on an escape
    -- sequence. The block Control Pictures has a symbol for each C0 control
    -- character, at its code point plus 0x2400, and one for DEL.
    visible :: Char -> Char
    visible c
      | c == '\t' = c
      | c == '\DEL' = '\x2421'
      | c < ' ' = chr (ord c + 0x2400)
      | isControl c = '\xFFFD'
      | otherwise = c

    ellipsis :: String
    ellipsis = "..."

    before :: Int
    before
      | short = back
      | otherwise = (if cutBefore then length ellipsis else 0) + inExcerpt

    T.Text arr lineStart lineLen = err.sourceLine

    lineEnd :: Int
    lineEnd = lineStart + lineLen

    -- The index of the location in the array, at the start of a character.
    index :: Int
    index = charStart (lineStart + max 0 (min lineLen err.sourceIndex))

    charStart :: Int -> Int
    charStart i
      | i > lineStart && i < lineEnd && not (isCharStart (A.unsafeIndex arr i)) =
          charStart (i - 1)
      | otherwise = i

    -- Step over at most the given number of characters before or from the
    -- location. Give the number of steps and the index in the array.
    stepBack, stepAhead :: Int -> (Int, Int)
    stepBack = go index 0
      where
        go :: Int -> Int -> Int -> (Int, Int)
        go i !k n
          | n == 0 || i <= lineStart = (k, i)
          | otherwise = go (charStart (i - 1)) (k + 1) (n - 1)
    stepAhead = go index 0
      where
        go :: Int -> Int -> Int -> (Int, Int)
        go i !k n
          | n == 0 || i >= lineEnd = (k, i)
          | otherwise = go (charEnd (i + 1)) (k + 1) (n - 1)

        charEnd :: Int -> Int
        charEnd i =
          if i < lineEnd && not (isCharStart (A.unsafeIndex arr i))
            then charEnd (i + 1)
            else i

    -- A tab before the column keeps the caret aligned in a terminal, and a
    -- combining mark takes no cell. A wide character, e.g. of CJK, takes two
    -- cells, so the caret is one cell to the left for each one before the
    -- column. base has no data on the width of characters, and the library
    -- does not keep a copy of the Unicode table for this.
    caret :: String
    caret = concatMap cell (take before shown)
      where
        cell :: Char -> String
        cell c = case generalCategory c of
          NonSpacingMark -> ""
          EnclosingMark -> ""
          _ -> if c == '\t' then "\t" else " "

-- | A path in the form @jobs[1].name@. A key that is a collection is @?@,
-- and a key that is an alias is its alias, e.g. @*base@.
--
-- A key is in double quotes, e.g. @\"a.b\"@, if it:
--
-- * is empty,
--
-- * has white space, a character that cannot be printed, or one of the
--   characters @.[]\"\\@,
--
-- * starts with @?@ or @*@.
--
-- In the quotes, a character that cannot be printed has an escape as in
-- YAML, e.g. @\"a\\nb\"@.
--
-- >>> renderPath (pathFromElements [Key "jobs", Index 1, Key "name"])
-- "jobs[1].name"
--
-- >>> renderPath (pathFromElements [Key "a.b", Key ""])
-- "\"a.b\".\"\""
renderPath :: Path -> String
renderPath path = case pathElements path of
  [] -> ""
  e : rest -> step e ++ concatMap next rest
  where
    next :: PathElement -> String
    next = \case
      Index i -> index i
      e -> "." ++ step e

    step :: PathElement -> String
    step = \case
      Key k -> key k
      Index i -> index i
      CollectionKey -> "?"
      AliasKey name -> '*' : T.unpack name

    key :: T.Text -> String
    key k
      | not (T.null k)
          && T.all plain k
          && not (T.isPrefixOf "?" k || T.isPrefixOf "*" k) =
          T.unpack k
      | otherwise = showText k
      where
        plain :: Char -> Bool
        plain c = notElem @[] c ".[]\"\\" && isPrint c && not (isSpace c)

    index :: Int -> String
    index i = "[" ++ show i ++ "]"

-- | The path from the root to the node at the offset. If several nodes start
-- there, e.g. a block mapping and its first key, the outermost one counts. A
-- key does not add to the path, and a node inside a key that is a collection
-- has the path of the mapping.
nodePath :: Offset -> Node -> Path
nodePath off root = fromMaybe Root (listToMaybe (nodePaths [off] root))

-- | The paths of 'nodePath' for several offsets, in the order of the offsets,
-- from one walk of the tree.
nodePaths :: [Offset] -> Node -> [Path]
nodePaths offs root = map (\off -> M.findWithDefault Root off found) offs
  where
    found :: M.Map Offset Path
    found = walk (Set.delete noOffset (Set.fromList offs)) Root root M.empty

    walk :: Set.Set Offset -> Path -> Node -> M.Map Offset Path -> M.Map Offset Path
    walk wanted path n acc
      | Set.null inside = here
      | otherwise = case n.content of
          SequenceContent _ xs ->
            L.foldl'
              (\a (i, x) -> walk inside (Child path (Index i)) x a)
              here
              (zip [0 ..] xs)
          MappingContent _ kvs ->
            L.foldl'
              ( \a (k, v) ->
                  walk inside (Child path (keyElement k)) v (key inside path k a)
              )
              here
              kvs
          _ -> here
      where
        here :: M.Map Offset Path
        here
          | n.offset `Set.member` wanted = M.insertWith (\_ old -> old) n.offset path acc
          | otherwise = acc

        inside :: Set.Set Offset
        inside = within n wanted

    -- Every node of a key has the path of the mapping. An index or a key
    -- inside the key would read as a step into the mapping.
    key :: Set.Set Offset -> Path -> Node -> M.Map Offset Path -> M.Map Offset Path
    key wanted path k acc =
      Set.foldl' (\a off -> M.insertWith (\_ old -> old) off path a) acc offsets
      where
        -- A value can start at the end of its key, e.g. the empty value in
        -- "{a}", so the end is not a node of the key, unless the key is empty.
        offsets :: Set.Set Offset
        offsets =
          Set.takeWhileAntitone (\o -> o < k.endOffset || o == k.offset) $
            Set.dropWhileAntitone (< k.offset) wanted

    -- A node that a program built has no offsets, but it can contain the
    -- nodes of a parsed input.
    within :: Node -> Set.Set Offset -> Set.Set Offset
    within n
      | n.offset == noOffset = id
      | otherwise =
          Set.takeWhileAntitone (<= n.endOffset) . Set.dropWhileAntitone (< n.offset)

    -- The texts of a parsed tree are slices of the input, which an error
    -- would keep alive.
    keyElement :: Node -> PathElement
    keyElement k = case k.content of
      ScalarContent _ t -> Key (T.copy t)
      AliasContent name -> AliasKey (T.copy name)
      _ -> CollectionKey

-- | Create an error at the given offset of the input.
errorAt :: T.Text -> Offset -> String -> Error
errorAt input off msg
  | noPosition input off = errorOnLine (locate input off) msg T.empty 0
  | otherwise =
      let (loc, index, _) = locateFrom input (startScan input) off
      in errorOnLine loc msg (T.copy (lineAt input off)) index

-- | An error at the location, with the line that contains it and the index of
-- the location in the bytes of the line.
errorOnLine :: Location -> String -> T.Text -> Int -> Error
errorOnLine loc msg sourceLine index =
  force $
    Error
      { location = loc
      , message = msg
      , sourceLine = sourceLine
      , sourceIndex = min (T.lengthWord8 sourceLine) index
      , path = Root
      }

-- | Create errors at the given offsets of a document, with their paths, in the
-- order of the list. The text is the input of the document, e.g. for the
-- offsets of t'Located' values.
documentErrors :: T.Text -> Document -> [(Offset, String)] -> [Error]
documentErrors input doc errs =
  zipWith
    (\err p -> force err {path = p})
    (errorsAt input errs)
    (nodePaths (map fst errs) doc.root)

-- | Create errors at the given offsets of the input, in the order of the
-- list. One scan of the input locates all of them, and the errors on one
-- line share the copy of the line.
errorsAt :: T.Text -> [(Offset, String)] -> [Error]
errorsAt input errs =
  map snd . L.sortOn fst $
    go (startScan input) Nothing (L.sortOn (fst . snd) (zip [0 :: Int ..] errs))
  where
    -- The line of the previous error, with the copy of its text.
    go :: Scan -> Maybe (Int, T.Text) -> [(Int, (Offset, String))] -> [(Int, Error)]
    go s prev = \case
      [] -> []
      (i, (off, msg)) : rest
        | noPosition input off ->
            (i, errorOnLine (locate input off) msg T.empty 0) : go s prev rest
        | otherwise ->
            let (loc, index, s') = locateFrom input s off
                sourceLine = case prev of
                  Just (ln, t) | ln == loc.line -> t
                  _ -> T.copy (lineAt input off)
            in (i, errorOnLine loc msg sourceLine index)
                 : go s' (Just (loc.line, sourceLine)) rest

-- | Compute the line and the column of an offset. The byte order marks at the
-- start of a line are not columns, because they are not content. For
-- 'noOffset' or an offset beyond the end of the input, the line and the
-- column are 0.
locate :: T.Text -> Offset -> Location
locate input off
  | noPosition input off = Location {offset = off, line = 0, column = 0}
  | otherwise = let (loc, _, _) = locateFrom input (startScan input) off in loc

-- | The offset has no position in the input: it is 'noOffset', or it is
-- beyond the end of the input, e.g. the offset of a parsed node in a
-- document that a program built, decoded with another text.
noPosition :: T.Text -> Offset -> Bool
noPosition (T.Text _ _ len) (Offset off) = off < 0 || off > len

-- | A scan of the input: the index, the line, the start of the columns of
-- the line, and an index on the line with its column. The columns of a line
-- start after its byte order marks.
data Scan = Scan !Int !Int !Int !Int !Int

startScan :: T.Text -> Scan
startScan (T.Text arr base len) = Scan base 1 start start 1
  where
    start :: Int
    start = skipBomsIn arr (base + len) base

-- | Locate an offset that is not before the index of the scan, and continue
-- the scan from there. Also give the index of the offset in the bytes of the
-- line from the start of its columns, or 0 for an offset in a byte order mark
-- at the start of the line.
locateFrom :: T.Text -> Scan -> Offset -> (Location, Int, Scan)
locateFrom (T.Text arr base len) s0 (Offset off0) = go s0
  where
    end, off :: Int
    end = base + len
    off = base + max 0 (min len off0)

    go :: Scan -> (Location, Int, Scan)
    go s@(Scan i ln ls ci col)
      | i >= off =
          if off <= ci
            -- An offset before the start of the columns is in a byte order
            -- mark.
            then (location ln (if off == ci then col else 1), max 0 (off - ls), s)
            else
              let col' = col + countChars ci off
              in (location ln col', off - ls, Scan i ln ls off col')
      | otherwise = case A.unsafeIndex arr i of
          LF -> newLine (i + 1)
          CR
            | i + 1 < end && A.unsafeIndex arr (i + 1) == LF ->
                go (Scan (i + 1) ln ls ci col)
            | otherwise -> newLine (i + 1)
          _ -> go (Scan (i + 1) ln ls ci col)
      where
        newLine :: Int -> (Location, Int, Scan)
        newLine j =
          let start' = skipBomsIn arr end j in go (Scan j (ln + 1) start' start' 1)

    location :: Int -> Int -> Location
    location ln col = Location {offset = Offset (off - base), line = ln, column = col}

    countChars :: Int -> Int -> Int
    countChars i0 i1 =
      length
        [() | i <- [i0 .. i1 - 1], isCharStart (A.unsafeIndex arr i)]

-- | The line of the input that contains the offset, without the line break
-- and without the byte order marks at its start.
lineAt :: T.Text -> Offset -> T.Text
lineAt (T.Text arr base len) (Offset off0) = T.Text arr start (stop - start)
  where
    end, i0, off, start, stop :: Int
    end = base + len
    i0 = base + max 0 (min len off0)

    -- An offset between the characters of a CRLF line break is on the line
    -- before the break.
    off
      | i0 > base
          && i0 < end
          && A.unsafeIndex arr i0 == LF
          && A.unsafeIndex arr (i0 - 1) == CR =
          i0 - 1
      | otherwise = i0
    start = skipBomsIn arr end (findStart off)
    stop = max start (findStop off)

    findStart :: Int -> Int
    findStart i
      | i > base && not (isBreak (A.unsafeIndex arr (i - 1))) = findStart (i - 1)
      | otherwise = i

    findStop :: Int -> Int
    findStop i
      | i < end && not (isBreak (A.unsafeIndex arr i)) = findStop (i + 1)
      | otherwise = i

-- $setup
-- >>> import Yamlet
-- >>> printErrors = mapM_ (putStrLn . prettyError "input.yaml")