packages feed

tadka-2.0.0.0: test/props/Hyperlink.hs

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

-- | OSC 8 hyperlinks (post-v1 hardening item 1). The resolver proofs pin
-- exactly how 'HyperlinkAuto' becomes concrete, one-for-one with
-- "TermColor"'s colour proofs; the render proofs pin that wrapping touches
-- only the @= see:@ URL and nothing else — so a diagnostic with no URL is
-- byte-identical whether hyperlinks are on or off, and 'HyperlinkNever'
-- output is always the escape-free baseline regardless of what the message,
-- help text, or URL contain.
--
-- Deliberately independent of "GenDiag"/"Fixtures": a small dedicated carrier
-- and generator here keep this module's coverage from being perturbed by an
-- unrelated change to generators several other suites also depend on.
module Hyperlink (group) where

import           Data.Text                  (Text)
import qualified Data.Text                  as T
import           Prettyprinter               (pretty)
import           Prettyprinter.Render.Text  (renderStrict)
import           Prettyprinter              (LayoutOptions (..), PageWidth (Unbounded),
                                             layoutPretty)

import           Hedgehog                   (Group (..), Gen, Property, forAll, property,
                                             withTests, (===), assert)
import qualified Hedgehog.Gen                as Gen
import qualified Hedgehog.Range              as Range

import           Tadka
import           Tadka.Internal.Config       (configHyperlinkMode)
import           Tadka.Internal.Terminal     (TerminalCaps (..), resolveConfig, resolveHyperlink)

group :: Group
group = Group "OSC 8 hyperlinks"
  [ ("explicit hyperlink modes pass through",                prop_explicitPassthrough)
  , ("auto hyperlink: NO_HYPERLINK always wins",             prop_noHyperlinkWins)
  , ("auto hyperlink: force beats tty",                      prop_forceHyperlink)
  , ("auto hyperlink: else follows tty",                     prop_ttyHyperlink)
  , ("resolveConfig eliminates HyperlinkAuto",               prop_noAutoAfter)
  , ("resolveConfig is idempotent on hyperlink mode",        prop_idempotentHyperlink)
  , ("HyperlinkNever never emits an escape",                 prop_neverNoEsc)
  , ("HyperlinkAlways wraps a present URL in OSC 8",         prop_alwaysWrapsUrl)
  , ("no URL: Always and Never render identically",         prop_noUrlNoDifference)
  , ("the wrap adds only the OSC 8 escape, nothing else",    prop_structureInvariant)
  , ("unresolved Auto behaves like Always (mirrors colour)", prop_autoUnresolvedWraps)
  , ("a raw ESC in the message is neutralised, not leaked",  prop_messageEscNeverLeaks)
  ]

-- === generators ============================================================

genCaps :: Gen TerminalCaps
genCaps = TerminalCaps <$> Gen.bool <*> Gen.bool <*> Gen.bool <*> Gen.bool <*> Gen.bool <*> Gen.bool

genHyperlinkMode :: Gen HyperlinkMode
genHyperlinkMode = Gen.element [HyperlinkAuto, HyperlinkAlways, HyperlinkNever]

-- A representative sample of the absolute URIs 'mkUrl' accepts: ordinary
-- https, an IPv6 host, a non-http scheme, userinfo + port + path params,
-- percent-encoding, a bare query string, and the shortest legal form (a
-- scheme with an opaque, non-hierarchical part). Verified individually
-- against 'mkUrl' while writing this module; kept as a fixed set (rather than
-- a from-scratch URI generator) so every case here is independently known
-- valid.
genUrlText :: Gen Text
genUrlText = Gen.element
  [ "https://example.org/errors/E0001"
  , "https://[::1]:8080/path"
  , "mailto:foo@example.com"
  , "a:b"
  , "https://example.com/a%20b"
  , "https://user:pass@host.example/path;p=1"
  , "urn:isbn:0451450523"
  , "https://example.com/x?y=1"
  , "https://example.com:65535/"
  , "file:///etc/passwd"
  ]

genUrl :: Gen Url
genUrl = mkUrlOrErr <$> genUrlText
  where mkUrlOrErr t = either (\e -> error ("Hyperlink.genUrl: " <> show e)) id (mkUrl t)

-- ASCII plus a literal ESC, so properties can confirm the escape is
-- neutralised by 'message''s existing control-character stripping rather than
-- leaking into the rendered report.
genMsgChar :: Gen Char
genMsgChar = Gen.frequency
  [ (8, Gen.filterT (/= '\n') (Gen.enum ' ' '~'))
  , (1, pure '\ESC')
  , (1, pure '\a')
  ]

genMsg :: Gen Text
genMsg = Gen.text (Range.linear 0 20) genMsgChar

-- | A minimal diagnostic carrier: only 'message' and (optionally) 'url' are
-- ever non-default here, which is all these properties need.
data HD = HD Text (Maybe Url)

instance Diagnostic HD where
  message (HD m _) = pretty m
  url     (HD _ u) = u

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

renderGfx :: HyperlinkMode -> HD -> Text
renderGfx hm d =
  case selectRenderer (withColorMode ColorNever (withUnicodeMode UnicodeAlways
                         (withHyperlinkMode hm (withTarget TGraphical defaultConfig)))) of
    SomeRenderer r@(Graphical _) -> renderStrict (layoutPretty (LayoutOptions Unbounded) (render r d))
    _                            -> ""

esc :: Char
esc = '\ESC'

-- | Remove OSC 8 escapes (@ESC ]8;; <label> ESC \\@ / @ESC ]8;; ESC \\@),
-- leaving whatever sits between the open and close escapes untouched. Total:
-- a malformed/truncated tail (never produced by 'hyperlink', only reachable if
-- a property here were given bad input) is left as-is rather than crashing.
stripOsc8 :: Text -> Text
stripOsc8 = T.pack . go . T.unpack
  where
    go [] = []
    go (c : ']' : '8' : ';' : ';' : rest)
      | c == esc = case break (== esc) rest of
          (_, _ : '\\' : rest') -> go rest'
          _                     -> rest
    go (c : rest) = c : go rest

-- === pure resolution proofs (mirrors "TermColor"'s colour proofs) =========

prop_explicitPassthrough :: Property
prop_explicitPassthrough = property $ do
  caps <- forAll genCaps
  resolveHyperlink caps HyperlinkAlways === HyperlinkAlways
  resolveHyperlink caps HyperlinkNever  === HyperlinkNever

prop_noHyperlinkWins :: Property
prop_noHyperlinkWins = property $ do
  caps <- forAll (fmap (\c -> c { capNoHyperlink = True }) genCaps)
  resolveHyperlink caps HyperlinkAuto === HyperlinkNever

prop_forceHyperlink :: Property
prop_forceHyperlink = property $ do
  caps <- forAll (fmap (\c -> c { capNoHyperlink = False, capForceHyperlink = True }) genCaps)
  resolveHyperlink caps HyperlinkAuto === HyperlinkAlways

prop_ttyHyperlink :: Property
prop_ttyHyperlink = property $ do
  caps0 <- forAll genCaps
  let caps = caps0 { capNoHyperlink = False, capForceHyperlink = False }
  resolveHyperlink caps HyperlinkAuto === (if capIsTerminal caps then HyperlinkAlways else HyperlinkNever)

prop_noAutoAfter :: Property
prop_noAutoAfter = property $ do
  caps <- forAll genCaps
  hm   <- forAll genHyperlinkMode
  let r = resolveConfig caps (withHyperlinkMode hm defaultConfig)
  assert (configHyperlinkMode r `elem` [HyperlinkAlways, HyperlinkNever])

prop_idempotentHyperlink :: Property
prop_idempotentHyperlink = property $ do
  caps <- forAll genCaps
  hm   <- forAll genHyperlinkMode
  let c1 = resolveConfig caps (withHyperlinkMode hm defaultConfig)
      c2 = resolveConfig caps c1
  configHyperlinkMode c2 === configHyperlinkMode c1

-- === render proofs ==========================================================

prop_neverNoEsc :: Property
prop_neverNoEsc = property $ do
  msg <- forAll genMsg
  mu  <- forAll (Gen.maybe genUrl)
  assert (not (T.any (== esc) (renderGfx HyperlinkNever (HD msg mu))))

prop_alwaysWrapsUrl :: Property
prop_alwaysWrapsUrl = property $ do
  msg <- forAll genMsg
  u   <- forAll genUrl
  let out    = renderGfx HyperlinkAlways (HD msg (Just u))
      label  = unUrl u
      wanted = "\ESC]8;;" <> label <> "\ESC\\" <> label <> "\ESC]8;;\ESC\\"
  assert (T.isInfixOf wanted out)

prop_noUrlNoDifference :: Property
prop_noUrlNoDifference = property $ do
  msg <- forAll genMsg
  renderGfx HyperlinkAlways (HD msg Nothing) === renderGfx HyperlinkNever (HD msg Nothing)

prop_structureInvariant :: Property
prop_structureInvariant = property $ do
  msg <- forAll genMsg
  u   <- forAll genUrl
  stripOsc8 (renderGfx HyperlinkAlways (HD msg (Just u))) === renderGfx HyperlinkNever (HD msg (Just u))

-- 'hyperlink' (like 'colorize') treats any mode that is not the "off" mode as
-- "on"; a raw, unresolved 'HyperlinkAuto' reaching the renderer therefore
-- behaves exactly like 'HyperlinkAlways'. Pinned as a single deterministic
-- case (not a property over generated input) because it documents a specific
-- design choice rather than a general law.
prop_autoUnresolvedWraps :: Property
prop_autoUnresolvedWraps = withTests 1 . property $
  let d = HD "type mismatch" (Just (rightOrErr (mkUrl "https://example.org/errors/E0001")))
  in renderGfx HyperlinkAuto d === renderGfx HyperlinkAlways d

-- A raw ESC embedded in the message text must never reach the rendered
-- report: 'Tadka.Internal.Renderer.Graphical.docToText' already strips
-- control characters from message/help/label text (unrelated to this
-- feature), and this hyperlink work must not weaken that. Checked
-- independently of 'prop_structureInvariant' (which proves the same thing via
-- structural equality): here every ESC in the output is counted directly, so
-- the proof holds even if the two OSC 8 escapes this feature adds were
-- themselves malformed in a way structural comparison could miss.
prop_messageEscNeverLeaks :: Property
prop_messageEscNeverLeaks = property $ do
  msg <- forAll genMsg
  u   <- forAll genUrl
  let out = renderGfx HyperlinkAlways (HD msg (Just u))
  T.count (T.singleton esc) out === 4   -- introducer + terminator, for both the open and close escapes