packages feed

tilia-0.1.0.0: src/Tilia/Comments/Place.hs

-- | Determine the placement of each 'Comment'.
module Tilia.Comments.Place
  ( Position (..),
    Alignment (..),
    Shape (..),
    shapeOf,
    Placements,
    placeComments,
    claimPlaced,
    unclaimedByEither,
    unplaced,
    nothingPlaced,
  )
where

import Control.Applicative ((<|>))
import Control.Monad (guard)
import Data.IntMap.Strict qualified as IntMap
import Data.List (minimumBy, sortOn)
import Data.List.NonEmpty (NonEmpty (..))
import Data.List.NonEmpty qualified as NE
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Ord (Down (..), comparing)
import Data.Set qualified as Set
import Tilia.Comments
  ( Above (..),
    Comment (..),
    carriedOnFrom,
    closesItself,
    commentTrailing,
    singleLine,
  )
import Tilia.Doc.Internal (Spill (..))
import Tilia.Span

-- | Where a comment is emitted in relation to its region.
data Position
  = -- | Before the region.
    Before
  | -- | After the region, on the line it ends on.
    After Spill
  | -- | On lines of its own under the region.
    Under Alignment
  deriving (Eq, Show)

-- | What a comment on lines of its own under a region is lined up with.
data Alignment
  = -- | The column the region begins at.
    ByTheRegion
  | -- | The indentation in force after the region.
    ByTheIndentation
  deriving (Eq, Show)

-- | How a comment is printed in relation to the region carrying it.
data Shape
  = -- | Spliced where the region prints, with code able to follow it on the
    -- same line.
    InPlace
  | -- | Printed where the region is, and the line closed after it.
    EndsTheLine
  | -- | Held back to the end of whatever line of output it lands on,
    -- however much of that line is still to be written.
    HeldBack Spill
  | -- | On lines of its own, keeping the empty lines the author left around
    -- it.
    OnItsOwnLines
  deriving (Eq, Show)

-- | What a comment given to a region at this position will look like.
shapeOf :: Position -> Comment -> Shape
shapeOf position c = case position of
  Before
    | closesItself c && commentFollowed c -> InPlace
    | commentTrailing c -> EndsTheLine
    | otherwise -> OnItsOwnLines
  After spill
    | closesItself c -> InPlace
    | singleLine c -> HeldBack spill
    | otherwise -> EndsTheLine
  Under _ -> OnItsOwnLines

-- | Comment placements not yet written: all of them when placement is
-- decided, fewer as a walk writes them.
data Placements = Placements
  { -- | The comments to write around each region.
    placedAt :: Map Span [(Position, Comment)],
    -- | The comments no region was found for.
    placedNowhere :: [Comment]
  }

instance Semigroup Placements where
  a <> b =
    Placements
      { placedAt = Map.unionWith (<>) (placedAt a) (placedAt b),
        placedNowhere = placedNowhere a <> placedNowhere b
      }

instance Monoid Placements where
  mempty = Placements Map.empty []

-- | Determine comment placements.
placeComments ::
  -- | The regions a comment may be given to.
  [Span] ->
  -- | The innermost region each region prints first, where that one was
  -- written above it.
  Map Span Span ->
  -- | The boundaries a comment printed in place may not be carried across.
  [Span] ->
  -- | The comments to place.
  [Comment] ->
  Placements
placeComments regions leads fences comments =
  Placements
    { placedAt = Map.fromListWith (flip (<>)) [(r, [(p, c)]) | (Just (r, p), c) <- decided],
      placedNowhere = [c | (Nothing, c) <- decided]
    }
  where
    decided = [(against c, c) | c <- comments]

    carriedOn = carriedOnFrom comments

    regionsByEndLine =
      IntMap.fromListWith (<>) [(spanEndLine r, [r]) | r <- regions]

    regionEndPoints = Set.fromList (fmap endPoint regions)

    regionsByStartPoint =
      Map.fromListWith wider [(startPoint r, r) | r <- regions]
      where
        wider a b = if endPoint a >= endPoint b then a else b

    against c
      | Just placed@(_, After _) <- asCommentBefore = Just placed
      | commentTrailing c,
        Just r <- trailed =
          Just (r, After SpillAtIndentation)
      | Just placed <- continues = Just placed
      | Just placed <- under = Just placed
      | Just r <- next =
          Just (maybe (r, Before) (,Under ByTheRegion) (Map.lookup r leads))
      | otherwise = Nothing
      where
        here = commentSpan c
        asCommentBefore = against =<< (`Map.lookup` commentsByEndPoint) =<< stopsAt
        trailed
          | writtenAgainst || not (commentFollowed c) =
              linedUpUnder <|> endingOn (spanStartLine here)
          | otherwise = Nothing
        linedUpUnder = do
          (top, _) <- IntMap.lookup (spanStartLine here + 1) runs
          (r, Under ByTheRegion) <- against top
          r <$ guard (candidate r)
        endingOn line =
          nearest (\r -> (Down (endPoint r), startPoint r)) (filter candidate onThatLine)
          where
            onThatLine = IntMap.findWithDefault [] line regionsByEndLine
        candidate r =
          endPoint r <= startPoint here
            && startPoint r /= endPoint r
            && not (fencedOff r)

        writtenAgainst = maybe False (`Set.member` regionEndPoints) stopsAt
        stopsAt = (,) (spanStartLine here) <$> commentCodeBeforeStopsAt c

        fencedOff r =
          outside (Map.lookup here enclosingRegions)
            || (printedInPlace && outside (Map.lookup here enclosingFences))
          where
            outside = maybe False (\(from, to) -> not (from <= startPoint r && endPoint r <= to))

        printedInPlace = shapeOf (After SpillAtIndentation) c == InPlace

        next = snd <$> Map.lookupGE (endPoint here) regionsByStartPoint

        continues
          | nothingBelowItLinesUp,
            Just (anchor, remark) <- carriedOn c =
              (,linedUpWith remark) <$> endingOn anchor
          | otherwise = Nothing

        linedUpWith remark
          | remark == spanStartColumn here = After SpillUnderPrevious
          | otherwise = After SpillAtIndentation

        nothingBelowItLinesUp =
          all (\r -> spanStartColumn r < spanStartColumn here) next

        under
          | Just (top, bottom) <- IntMap.lookup (spanStartLine here) runs,
            Just (line, column) <- commentNextLine bottom,
            column /= spanStartColumn here,
            any ((<= (line, column)) . startPoint) next =
              lineOfCode top
          | otherwise = Nothing

        lineOfCode top = do
          r <- nearest (Down . startPoint) (filter linedUp onThatLine)
          guard (any (larger r) onThatLine)
          pure (r, Under (alignedBy r))
          where
            line = spanStartLine (commentSpan top) - 1
            onThatLine = IntMap.findWithDefault [] line regionsByEndLine
            lineEnd = maximum (fmap endPoint onThatLine)
            linedUp r =
              startPoint r /= endPoint r
                && endPoint r == lineEnd
                && (startsUnderIt r || holdsTheLine r)
                && not (fencedOff r)
            startsUnderIt r =
              spanStartColumn r == spanStartColumn here
                && (spanStartLine r == line || startsItsLine r)
            holdsTheLine r =
              commentAbove top == ContentAt (spanStartColumn here)
                && startPoint r <= (line, spanStartColumn here)
            alignedBy r
              | startsUnderIt r = ByTheRegion
              | otherwise = ByTheIndentation
            startsItsLine r =
              maybe True ((< spanStartLine r) . fst . fst) $
                Map.lookupLT (startPoint r) regionsByStartPoint
            larger r o = startPoint o < startPoint r && endPoint o == lineEnd

    commentsByEndPoint = Map.fromList [(endPoint (commentSpan c), c) | c <- comments]

    runs =
      IntMap.fromList
        [ (spanStartLine (commentSpan c), (NE.head run, NE.last run))
        | run <- foldr joined [] (sortOn (startPoint . commentSpan) comments),
          c <- NE.toList run
        ]
      where
        joined c rest
          | commentTrailing c || commentFollowed c = rest
          | (d :| ds) : rest' <- rest, continuedBy c d = (c :| d : ds) : rest'
          | otherwise = (c :| []) : rest
        continuedBy c d =
          spanStartLine below == spanEndLine above + 1
            && spanStartColumn below == spanStartColumn above
          where
            above = commentSpan c
            below = commentSpan d

    enclosingRegions = enclosures regions (fmap commentSpan comments)
    enclosingFences = enclosures fences (fmap commentSpan comments)

    nearest :: (Ord k) => (Span -> k) -> [Span] -> Maybe Span
    nearest key = fmap (minimumBy (comparing key)) . NE.nonEmpty

-- | For each inner span that outer spans enclose, the latest start and the
-- earliest end among them.
enclosures :: [Span] -> [Span] -> Map Span ((Int, Int), (Int, Int))
enclosures outer inner =
  Map.fromList (go (sortOn startPoint outer) Map.empty Set.empty (sortOn startPoint inner))
  where
    go _ _ _ [] = []
    go os latest ends (i : is) =
      let (opened, later) = span ((<= startPoint i) . startPoint) os
          latest' = foldl' admit latest opened
          ends' = foldl' (flip (Set.insert . endPoint)) ends opened
          bounds = do
            (_, from) <- Map.lookupGE (endPoint i) latest'
            to <- Set.lookupGE (endPoint i) ends'
            pure (i, (from, to))
       in maybe id (:) bounds (go later latest' ends' is)
    -- A span opened later starts no earlier, so it supersedes those that end
    -- no later than it does.
    admit latest o =
      Map.insert (endPoint o) (startPoint o) (Map.dropWhileAntitone (<= endPoint o) latest)

-- | Claim the comments that belong to the given 'Span'.
claimPlaced :: Span -> Placements -> ([(Position, Comment)], Placements)
claimPlaced s p = case Map.updateLookupWithKey forget s (placedAt p) of
  (found, rest) -> (concat found, p{placedAt = rest})
  where
    forget _ _ = Nothing

-- | The placements neither of two walks from the same placements claimed.
unclaimedByEither ::
  Placements ->
  Placements ->
  Placements
unclaimedByEither a b =
  a{placedAt = Map.intersection (placedAt a) (placedAt b)}

-- | The comments no region ever came to collect.
unplaced :: Placements -> [Comment]
unplaced p =
  sortOn (startPoint . commentSpan) $
    placedNowhere p <> foldMap (fmap snd) (placedAt p)

-- | Is there nothing left for a region to collect?
nothingPlaced :: Placements -> Bool
nothingPlaced = Map.null . placedAt