packages feed

tadka-2.0.0.0: test/props/EdgeCases.hs

{-# LANGUAGE GADTs #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}

-- | Production edge cases mined from miette's bug-fix history (learning from
-- their scars so tadka is robust at release). Each property names the miette
-- issue it mirrors. "No crash" is enforced by forcing the rendered length
-- inside 'try'; the rest assert structural expectations.
module EdgeCases (group) where

import           Control.Exception          (SomeException, evaluate, try)
import           Control.Monad.IO.Class      (liftIO)
import qualified Data.Aeson                 as A
import           Data.Text                  (Text)
import           Data.Char                  (isControl)
import qualified Data.Text                  as T
import qualified Data.Text.Lazy             as TL
import qualified Data.Text.Lazy.Encoding    as TLE
import           Prettyprinter              (LayoutOptions (..), PageWidth (Unbounded),
                                             layoutPretty, pretty)
import           Prettyprinter.Render.Text  (renderStrict)

import           Hedgehog
import qualified Hedgehog.Gen               as Gen
import qualified Hedgehog.Range             as Range

import           Tadka
import           Tadka.Internal             (buildContext)

group :: Group
group = Group "Production edge cases (from miette)"
  [ ("zero-length span renders a point (miette #204/#159/#32)", prop_zeroLen)
  , ("zero-length span at end of line (miette #204)",           prop_zeroLenEol)
  , ("span past end of line does not crash (miette #221)",      prop_pastEol)
  , ("span past EOF is stale, not a crash (miette #347)",       prop_pastEof)
  , ("empty source does not crash (miette #183)",               prop_emptySource)
  , ("label at offset 0 (miette 2.1.0)",                        prop_offsetZero)
  , ("CRLF line endings leave no stray CR (miette #37)",        prop_crlf)
  , ("wide chars + tabs do not crash (miette #202)",            prop_wideTab)
  , ("combining marks do not crash (miette #312/#314)",         prop_combining)
  , ("nested / overlapping spans both render (miette #316)",    prop_nested)
  , ("newline inside a label does not corrupt layout (#318)",   prop_newlineLabel)
  , ("multi-line span shows every intermediate line (#81)",     prop_noSkip)
  , ("totality over out-of-range spans (all targets)",          prop_totality)
  , ("no terminal-escape injection from source/labels (VULN #1)", prop_noInjection)
  , ("output is bounded for a huge multi-line span (VULN #2)",    prop_bounded)
  ]

rightOrErr :: Show a => Either a b -> b
rightOrErr = either (error . show) id

-- Build + graphically render a single-label diagnostic from a source and a span.
data Ed = Ed Context
instance Diagnostic Ed where
  message _      = "edge"
  context (Ed c) = c

ctxOf :: Text -> Int -> Int -> Text -> Context
ctxOf srcTxt off len lbl =
  buildContext (rightOrErr (mkNamedSource "e.hs" srcTxt))
               [ (rightOrErr (mkSpan off len), Just (pretty lbl)) ]

gfx :: Context -> Text
gfx c = case selectRenderer (withColorMode ColorNever (withUnicodeMode UnicodeAlways (withTarget TGraphical defaultConfig))) of
  SomeRenderer r@(Graphical _) -> renderStrict (layoutPretty (LayoutOptions Unbounded) (render r (Ed c)))
  _                            -> ""

renderAll :: Context -> [Text]
renderAll c =
  [ gfx c
  , case selectRenderer (withTarget TNarratable defaultConfig) of
      SomeRenderer r@(Narratable _) -> render r (Ed c); _ -> ""
  , case selectRenderer (withTarget TJson defaultConfig) of
      SomeRenderer r@(Json _) -> TL.toStrict (TLE.decodeUtf8 (A.encode (render r (Ed c)))); _ -> ""
  ]

noCrash :: [Text] -> PropertyT IO ()
noCrash outs = do
  res <- liftIO (try (evaluate (sum (map T.length outs))) :: IO (Either SomeException Int))
  case res of
    Right _ -> success
    Left e  -> annotate (show e) >> failure

-- === the cases ============================================================

prop_zeroLen :: Property
prop_zeroLen = withTests 1 . property $ do
  let out = gfx (ctxOf "abcdef" 2 0 "here")
  noCrash [out]
  assert ("^" `T.isInfixOf` out)              -- a point still gets a caret

prop_zeroLenEol :: Property
prop_zeroLenEol = withTests 1 . property $
  noCrash (renderAll (ctxOf "abc\ndef" 3 0 "eol"))    -- offset 3 = the newline

prop_pastEol :: Property
prop_pastEol = withTests 1 . property $
  noCrash (renderAll (ctxOf "abc\ndef" 1 10 "long"))  -- length runs past the line

prop_pastEof :: Property
prop_pastEof = withTests 1 . property $ do
  let out = gfx (ctxOf "abc" 100 3 "gone")            -- entirely out of bounds
  noCrash [out]
  assert ("unavailable" `T.isInfixOf` out)            -- stale reason surfaces

prop_emptySource :: Property
prop_emptySource = withTests 1 . property $
  noCrash (renderAll (ctxOf "" 0 0 "empty"))

prop_offsetZero :: Property
prop_offsetZero = withTests 1 . property $ do
  let out = gfx (ctxOf "abcdef" 0 3 "start")
  noCrash [out]
  assert ("e.hs:1:1" `T.isInfixOf` out)               -- points at column 1

prop_crlf :: Property
prop_crlf = withTests 1 . property $ do
  -- 'def' is on line 2; offset of 'd' = len "abc\r\n" = 5
  let out = gfx (ctxOf "abc\r\ndef\r\nghi" 5 3 "on line two")
  noCrash [out]
  assert (not ('\r' `T.elem` out))                    -- no stray carriage returns
  assert ("e.hs:2:1" `T.isInfixOf` out)               -- correct line/col

prop_wideTab :: Property
prop_wideTab = withTests 1 . property $
  noCrash [gfx (ctxOf "\t\x4E2D\x6587 x = 1" 4 1 "wide+tab")]   -- tab + CJK before span

prop_combining :: Property
prop_combining = withTests 1 . property $
  noCrash [gfx (ctxOf "e\x0301clair = 1" 0 7 "accented")]        -- combining acute

prop_nested :: Property
prop_nested = withTests 1 . property $ do
  let c = buildContext (rightOrErr (mkNamedSource "e.hs" "abcdefgh"))
            [ (rightOrErr (mkSpan 1 6), Just "outer")
            , (rightOrErr (mkSpan 2 2), Just "inner") ]
      out = gfx c
  noCrash [out]
  assert ("outer" `T.isInfixOf` out && "inner" `T.isInfixOf` out)

prop_newlineLabel :: Property
prop_newlineLabel = withTests 1 . property $ do
  let out = gfx (ctxOf "abcdef" 1 3 "line one\nline two")
  noCrash [out]
  -- the caret rail must not be broken: every non-empty output line after the
  -- header should still start with a gutter/rail column, i.e. the injected
  -- newline must not produce a bare "line two" with no rail.
  assert (not (any (== "line two") (T.lines out)))

prop_noSkip :: Property
prop_noSkip = withTests 1 . property $ do
  -- multi-line span lines 1..4 in a 5-line source; all four must appear
  let src5 = "L1xxx\nL2xxx\nL3xxx\nL4xxx\nL5xxx"
      out  = gfx (ctxOf src5 0 22 "spans four lines")   -- offset 0..21 -> line 1..4
  noCrash [out]
  assert (all (\n -> n `T.isInfixOf` out) ["L1xxx", "L2xxx", "L3xxx", "L4xxx"])

prop_totality :: Property
prop_totality = property $ do
  off <- forAll (Gen.int (Range.linear 0 40))
  len <- forAll (Gen.int (Range.linear 0 40))
  let c = buildContext (rightOrErr (mkNamedSource "e.hs" "abc\ndef\nghi"))
            [ (rightOrErr (mkSpan off len), Just "x") | off + len <= 200 ]
  noCrash (renderAll c)

-- Text mixing printable and control characters (ESC, C0, C1, DEL).
genCtrlText :: Gen Text
genCtrlText = Gen.text (Range.linear 0 24) $ Gen.frequency
  [ (6, Gen.enum ' ' '~')
  , (2, Gen.enum '\x00' '\x1F')   -- C0 controls (incl ESC, BEL, BS)
  , (1, pure '\x7F')               -- DEL
  , (1, Gen.enum '\x80' '\x9F')   -- C1 controls
  ]

-- VULN #1: no raw control character (other than the '\n' line separator) may
-- appear in the terminal-facing output, no matter what the source or label
-- contains.
prop_noInjection :: Property
prop_noInjection = property $ do
  srcTxt <- forAll genCtrlText
  lbl    <- forAll genCtrlText
  off    <- forAll (Gen.int (Range.linear 0 (max 0 (T.length srcTxt))))
  let c    = buildContext (rightOrErr (mkNamedSource "s.hs" srcTxt))
                          [ (rightOrErr (mkSpan off 1), Just (pretty lbl)) ]
      bad t = [ ch | ch <- T.unpack t, isControl ch, ch /= '\n' ]
  mapM_ (\out -> bad out === []) (renderAll c)

-- VULN #2: a span across a huge line range must not render output proportional
-- to the span (default config falls back to a bounded context window).
prop_bounded :: Property
prop_bounded = withTests 1 . property $ do
  let big = T.intercalate (T.singleton '\n') (replicate 10000 "x")
      c   = buildContext (rightOrErr (mkNamedSource "s.hs" big))
                         [ (rightOrErr (mkSpan 0 (10000 * 2 - 3)), Just "huge") ]
  assert (length (T.lines (gfx c)) < 40)