packages feed

ormolu-0.9.0.0: src/Ormolu/Comments/Invariants.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}

-- | Properties that comment handling has to satisfy, and the check that
-- enforces them.
--
-- Every comment of the input should come out exactly once, and in the order
-- it went in. The check runs on every run of Ormolu, alongside the check
-- that the AST is unchanged, and is disabled by the same @--unsafe@ flag.
--
-- This is the half of comment checking that works on where the comments went
-- rather than on what they say. It compares the /spans/ of the comments a
-- module started with against the spans recorded as the printer emitted
-- them, so it can name the comment that was dropped, duplicated, invented
-- or moved.
--
-- It does /not/ look at the text of a comment at all: rendering one with
-- its contents mangled would pass. That is the other half, and it belongs
-- to 'Ormolu.Diff.ParseResult.diffCommentStream', which compares text and
-- ignores position. Neither check subsumes the other and both run by
-- default.
--
-- Haddocks are outside both halves. GHC's parser makes them part of the AST
-- rather than leaving them in the comment stream, so they are neither among
-- the comments a module started with nor in what the text check compares.
-- Losing or duplicating one changes the AST itself, and that is caught by
-- the third check, 'Ormolu.Diff.ParseResult.diffParseResult' comparing the
-- two syntax trees.
module Ormolu.Comments.Invariants
  ( InvariantViolation (..),
    checkCommentInvariants,
    renderInvariantViolation,
  )
where

import Data.List (sort)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as T
import GHC.Types.SrcLoc
import Ormolu.Printer.CommentPlacement

-- | A way in which the emitted comments failed to correspond to the
-- comments of the input.
data InvariantViolation
  = -- | A comment of the input was never emitted
    CommentDropped RealSrcSpan
  | -- | A comment was emitted more than once, the given number of times
    CommentDuplicated RealSrcSpan Int
  | -- | A comment was emitted that does not correspond to any comment of
    -- the input
    CommentInvented RealSrcSpan
  | -- | A comment was emitted after one that comes later in the input. The
    -- first span is the comment that was emitted too late, the second is
    -- the one it should have preceded.
    CommentReordered RealSrcSpan RealSrcSpan
  deriving (Eq, Show)

-- | Compare the comments of a snippet against the comments that were
-- emitted while rendering it.
checkCommentInvariants ::
  -- | Spans of all the comments the snippet started with
  [RealSrcSpan] ->
  -- | Spans of the elements the formatter is allowed to reorder, so that
  -- the comments travelling with them are exempt from the order check
  [RealSrcSpan] ->
  -- | Placements recorded while rendering it, in the order of emission
  [CommentPlacement] ->
  [InvariantViolation]
checkCommentInvariants inputSpans reorderable placements =
  dropped <> duplicated <> invented <> reordered
  where
    emitted = cpSpan <$> placements
    -- Pragmas and imports are deliberately sorted and the comments attached
    -- to them travel along, so the order they come out in says nothing.
    -- They are still expected to come out exactly once, which is what
    -- catches a comment being duplicated.
    ordered =
      [ spn
      | CommentPlacement {cpSpan = spn, cpSlot} <- placements,
        cpSlot /= SlotPragma,
        not (travelsWithAReorderedElement cpSlot)
      ]
    travelsWithAReorderedElement slot = case slotAnchor slot of
      Nothing -> False
      Just anchor -> any (`containsSpan` anchor) reorderable
    inputSet = Map.fromList ((,()) <$> inputSpans)
    counts = Map.fromListWith (+) ((,1 :: Int) <$> emitted)

    dropped =
      [CommentDropped spn | spn <- sort inputSpans, not (spn `Map.member` counts)]
    duplicated =
      [ CommentDuplicated spn n
      | (spn, n) <- Map.toAscList counts,
        n > 1
      ]
    invented =
      [ CommentInvented spn
      | spn <- Map.keys counts,
        not (spn `Map.member` inputSet)
      ]

    -- Only the first emission of each comment is considered, so that a
    -- comment reported as duplicated is not also reported as reordered.
    reordered = go [] (dedupe [] ordered)
      where
        dedupe _ [] = []
        dedupe seen (x : xs)
          | x `elem` seen = dedupe seen xs
          | otherwise = x : dedupe (x : seen) xs
        go _ [] = []
        go seen (x : xs) =
          [CommentReordered x y | y <- seen, x < y]
            <> go (x : seen) xs

-- | Render a violation as a single line.
renderInvariantViolation :: InvariantViolation -> Text
renderInvariantViolation = \case
  CommentDropped spn ->
    "dropped     " <> renderSpan spn
  CommentDuplicated spn n ->
    "duplicated  " <> renderSpan spn <> " (emitted " <> showT n <> " times)"
  CommentInvented spn ->
    "invented    " <> renderSpan spn
  CommentReordered spn before ->
    "reordered   " <> renderSpan spn <> " (emitted after " <> renderSpan before <> ")"

renderSpan :: RealSrcSpan -> Text
renderSpan spn =
  renderLoc (realSrcSpanStart spn) <> "-" <> renderLoc (realSrcSpanEnd spn)
  where
    renderLoc l = showT (srcLocLine l) <> ":" <> showT (srcLocCol l)

showT :: (Show a) => a -> Text
showT = T.pack . show