packages feed

snippet-0.1.0.3: src/Snippet/Line.hs

-- |
--
-- Module      : Snippet.Line
-- Copyright   : (c) 2026 Patrick Brisbin
-- License     : AGPL-3
-- Maintainer  : pbrisbin@gmail.com
-- Stability   : experimental
-- Portability : POSIX
module Snippet.Line
  ( Line
  , lineNumber
  , lineContent
  , linesFromRegion
  ) where

import Prelude

import Control.Applicative ((<|>))
import Control.Monad (guard)
import Data.Foldable (toList)
import Data.List.NonEmpty (NonEmpty)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Prettyprinter (Doc, alterAnnotations, annotate, pretty, reAnnotate)
import Snippet.Pretty.Ann
import Snippet.Region

data Line = Line
  { number :: LineNumber
  , length :: Int
  -- ^ Length
  --
  -- Stored at construction because 'Color' will lose it
  , content :: LineContent
  }

data LineNumber
  = At Int
  | Under Int

data LineContent
  = Plain Int Text
  | Color (Doc Ann)

lineAt :: Int -> Text -> Line
lineAt n t =
  Line
    { number = At n
    , length = T.length t
    , content = Plain 0 t
    }

linesAt :: Int -> NonEmpty Text -> [Line]
linesAt n = zipWith lineAt [n ..] . toList

-- | Return a line's number (only if it's a real, numbered line)
lineNumber :: Line -> Maybe Int
lineNumber line =
  case line.number of
    At n -> Just n
    _ -> Nothing

-- | Return a line's number, or that of the one it's under
lineNumber' :: Line -> Int
lineNumber' line =
  case line.number of
    At n -> n
    Under n -> n

lineContent :: Line -> Doc Ann
lineContent = alterAnnotations dropPlain . lineContent'
 where
  -- We use zero-focus annotations to mark parts of text that can/should be
  -- re-annotated when nested. But they add extra reset escapes to the output,
  -- so we'll drop them at the end of processing.
  dropPlain = \case
    AnnFocused 0 -> []
    ann -> [ann]

-- | 'lineContent', but without dropping the @'AnnFocused' 0@ annotations
lineContent' :: Line -> Doc Ann
lineContent' line =
  case line.content of
    Plain i t -> plain $ T.replicate i " " <> t
    Color d -> d

-- | Built (focused) lines from a region and source lines
linesFromRegion :: Region -> Maybe Int -> NonEmpty Text -> [Line]
linesFromRegion region mStartLine = insideOut focusRegion region . linesAt sl
 where
  sl = fromMaybe region.startLine mStartLine

focusRegion :: Region -> [Line] -> [Line]
focusRegion region = alterLines $ \line -> do
  let
    sl = region.startLine
    el = fromMaybe sl region.endLine
    sc = fromMaybe 1 region.startColumn
    ec = fromMaybe (line.length + 1) region.endColumn

    oneLine = do
      guard $ el == sl
      n <- lineNumber line
      guard $ n == region.startLine

      -- inside-out and one-line rules mean we'll never see indent here
      Plain 0 content <- pure line.content

      let
        spaces = T.replicate (sc - 1) " "
        marker = T.replicate (ec - sc) "^"

        (pre, marked, post) = markLine sc (Just ec) content

        doc = plain pre <> focus marked <> plain post

      pure
        [ line {content = Color doc}
        , underline n spaces marker $ regionMessage (ec + 2) region
        ]

    startLine = do
      n <- lineNumber line
      guard $ n == sl
      Plain i content <- pure line.content

      let (pre, marked, post) = markLine sc (Just ec) content

      if T.null $ T.strip pre
        then do
          let
            marker = "╭─" <> T.replicate (i + sc - 2) "─"
            doc =
              focus (marker <> " " <> marked <> post)
                <> regionMessage (i + ec + 2) region

          pure [line {content = Color doc}]
        else do
          let
            marker = "╭─" <> T.replicate (i + sc - 1) "─" <> "^"
            doc =
              "  "
                <> pretty (T.replicate i " ")
                <> pretty pre
                <> focus (marked <> post)

          pure
            [ line {content = Color doc}
            , underline n "" marker $ regionMessage (i + ec) region
            ]

    inRegion = do
      let n = lineNumber' line
      guard $ n > sl && n < el

      pure [line {content = Color $ inRegionLine line}]

    endLine = do
      n <- lineNumber line
      guard $ n == el
      Plain i _ <- pure line.content

      let marker = "╰─" <> T.replicate (i + ec - 2) "─" <> "^"

      pure
        [ line {content = Color $ inRegionLine line}
        , underline n "" marker mempty
        ]

    shiftLine = do
      -- Don't do this for one-line regions
      guard $ el /= sl
      Plain i c <- pure line.content
      guard $ not $ T.null c
      pure [line {content = Plain (i + 2) c}]

  oneLine <|> startLine <|> inRegion <|> endLine <|> shiftLine <|> Just [line]

inRegionLine :: Line -> Doc Ann
inRegionLine line =
  if line.length == 0
    then focus "│"
    else focus "│ " <> refocus (lineContent' line)

underline :: Int -> Text -> Text -> Doc Ann -> Line
underline n prefix marker post =
  Line
    { number = Under n
    , length = T.length prefix + T.length marker
    , content = Color $ pretty prefix <> focus marker <> post
    }

-- | Run a function on nested 'Region's on sub-regions before parents
insideOut :: (Region -> a -> a) -> Region -> a -> a
insideOut f region a = case region.subRegion of
  Nothing -> f region a
  Just sr -> f region $ insideOut f sr a

alterLines :: (Line -> Maybe [Line]) -> [Line] -> [Line]
alterLines f = concatMap $ \l -> fromMaybe [l] $ f l

plain :: Text -> Doc Ann
plain = annotate (AnnFocused 0) . pretty

focus :: Text -> Doc Ann
focus = annotate (AnnFocused 1) . pretty

markLine :: Int -> Maybe Int -> Text -> (Text, Text, Text)
markLine sc mec t = (pre, marked, post)
 where
  (pre, rest) = T.splitAt (sc - 1) t
  (marked, post) = case mec of
    Nothing -> (rest, "")
    Just ec -> T.splitAt (ec - sc) rest

refocus :: Doc Ann -> Doc Ann
refocus = reAnnotate go
 where
  go = \case
    AnnFocused i -> AnnFocused $ i + 1
    x -> x

regionMessage :: Int -> Region -> Doc Ann
regionMessage sc region = maybe mempty (message sc) region.message

message :: Int -> Text -> Doc Ann
message sc m =
  mconcat
    [ " "
    , annotate AnnDecoration ("•" <> pretty bar <> "┤")
    , annotate AnnMessage $ pretty m
    ]
 where
  bar = T.replicate (max 2 barLen) "─"
  barLen = rightEdge - T.length m - sc - 3

rightEdge :: Int
rightEdge = 72