packages feed

fourmolu-0.21.0.0: src/Ormolu/Imports/Grouping.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE NoFieldSelectors #-}

module Ormolu.Imports.Grouping
  ( Import (..),
    ImportList (..),
    GroupImportsOpts (..),
    prepareExistingGroups,
    groupImports,
  )
where

import Data.Bifunctor (Bifunctor (..))
import Data.Foldable (toList)
import Data.Function (on)
import Data.List (groupBy, minimumBy, sortOn)
import Data.List.NonEmpty (NonEmpty)
import Data.List.NonEmpty qualified as NonEmpty
import Data.Map qualified as Map
import Data.Maybe (fromMaybe)
import Data.Set (Set)
import Data.Set qualified as Set
import Distribution.ModuleName qualified as Cabal
import GHC.Hs (GhcPs, LEpaComment, epaLocationRealSrcSpan, getLocA)
import GHC.Types.SrcLoc (getLoc, srcSpanEndLine, srcSpanStartLine, srcSpanToRealSrcSpan)
import Language.Haskell.Syntax (LImportDecl, ModuleName, moduleNameString)
import Ormolu.Config (ImportGroup (..), ImportGroupRule (..), ImportGrouping (..))
import Ormolu.Config qualified as Config
import Ormolu.Utils (ghcModuleNameToCabal, groupBy')
import Ormolu.Utils.Glob (matchAllGlob, matchesGlob)

newtype ImportGroups = ImportGroups (NonEmpty ImportGroup)

data Import = Import
  { importName :: ModuleName,
    importList :: Maybe ImportList,
    importQualified :: Bool
  }

data ImportList
  = ImportList
  | HidingList
  deriving (Eq)

importGroupSingleStrategy :: ImportGroups
importGroupSingleStrategy =
  ImportGroups $
    pure
      ImportGroup
        { igName = Nothing,
          igRules = pure matchAllImportRule
        }

importGroupByQualifiedStrategy :: ImportGroups
importGroupByQualifiedStrategy =
  ImportGroups $
    NonEmpty.fromList
      [ ImportGroup
          { igName = Nothing,
            igRules = pure $ withUnqualifiedOnly matchAllImportRule
          },
        ImportGroup
          { igName = Nothing,
            igRules = pure $ withQualifiedOnly matchAllImportRule
          }
      ]

importGroupByScopeStrategy :: ImportGroups
importGroupByScopeStrategy =
  ImportGroups $
    NonEmpty.fromList
      [ ImportGroup
          { igName = Nothing,
            igRules = pure matchAllImportRule
          },
        ImportGroup
          { igName = Nothing,
            igRules = pure matchLocalModulesRule
          }
      ]

importGroupByScopeThenQualifiedStrategy :: ImportGroups
importGroupByScopeThenQualifiedStrategy =
  ImportGroups $
    NonEmpty.fromList
      [ ImportGroup
          { igName = Nothing,
            igRules = pure $ withQualified matchModule
          }
      | matchModule <- [matchAllImportRule, matchLocalModulesRule],
        withQualified <- [withUnqualifiedOnly, withQualifiedOnly]
      ]

groupsFromConfig :: Config.ImportGrouping -> ImportGroups
groupsFromConfig =
  \case
    Config.ImportGroupLegacy -> importGroupSingleStrategy
    Config.ImportGroupPreserve -> importGroupSingleStrategy
    Config.ImportGroupSingle -> importGroupSingleStrategy
    Config.ImportGroupByQualified -> importGroupByQualifiedStrategy
    Config.ImportGroupByScope -> importGroupByScopeStrategy
    Config.ImportGroupByScopeThenQualified -> importGroupByScopeThenQualifiedStrategy
    Config.ImportGroupCustom igs -> ImportGroups igs

matchAllImportRule :: ImportGroupRule
matchAllImportRule =
  ImportGroupRule
    { igrGlob = matchAllGlob,
      igrImportListMatcher = Config.MatchAnyImportDeclaration,
      igrQualifiedMatcher = Config.MatchBothQualifiedAndUnqualified,
      igrScopeMatcher = Config.MatchAllModules,
      igrPriority = Config.matchAllRulePriority
    }

matchLocalModulesRule :: ImportGroupRule
matchLocalModulesRule =
  ImportGroupRule
    { igrGlob = matchAllGlob,
      igrImportListMatcher = Config.MatchAnyImportDeclaration,
      igrQualifiedMatcher = Config.MatchBothQualifiedAndUnqualified,
      igrScopeMatcher = Config.MatchLocalModules,
      igrPriority = Config.matchLocalRulePriority
    }

withQualifiedOnly :: ImportGroupRule -> ImportGroupRule
withQualifiedOnly ImportGroupRule {..} =
  ImportGroupRule
    { igrQualifiedMatcher = Config.MatchQualifiedOnly,
      ..
    }

withUnqualifiedOnly :: ImportGroupRule -> ImportGroupRule
withUnqualifiedOnly ImportGroupRule {..} =
  ImportGroupRule
    { igrQualifiedMatcher = Config.MatchUnqualifiedOnly,
      ..
    }

matchesRule :: Set Cabal.ModuleName -> Import -> ImportGroupRule -> Bool
matchesRule localMods Import {..} ImportGroupRule {..} =
  and
    [ matchingGlob,
      matchingImportList,
      matchingQualified,
      matchingScope
    ]
  where
    matchingGlob = moduleNameString importName `matchesGlob` igrGlob
    matchingImportList = case igrImportListMatcher of
      Config.MatchExplicitImportList -> importList == Just ImportList
      Config.MatchHidingImportClause -> importList == Just HidingList
      Config.MatchWholeModuleImport -> importList == Nothing
      Config.MatchAnyImportDeclaration -> True
    matchingQualified = case igrQualifiedMatcher of
      Config.MatchQualifiedOnly -> importQualified
      Config.MatchUnqualifiedOnly -> not importQualified
      Config.MatchBothQualifiedAndUnqualified -> True
    matchingScope =
      let isLocalModule = ghcModuleNameToCabal importName `Set.member` localMods
       in case igrScopeMatcher of
            Config.MatchAllModules -> True
            Config.MatchExternalModules -> not isLocalModule
            Config.MatchLocalModules -> isLocalModule

data GroupImportsOpts = GroupImportsOpts
  { grouping :: ImportGrouping,
    respectful :: Bool,
    -- | All comments in the HsModule.
    --
    -- Can't retrieve comments from 'R', since 'R' runs the first time without
    -- comments.
    allComments :: [LEpaComment]
  }

prepareExistingGroups :: GroupImportsOpts -> [LImportDecl GhcPs] -> [[LImportDecl GhcPs]]
prepareExistingGroups opts =
  case opts.grouping of
    ImportGroupPreserve -> preserveGroups
    ImportGroupLegacy | opts.respectful -> preserveGroups
    _ -> flattenGroups
  where
    preserveGroups = map toList . groupBy' (\x y -> not $ separatedByBlank' x y)
    flattenGroups = pure

    -- separatedByBlank only checks if the span lines are more than 1 apart.
    -- If there's a comment between two imports with no blank lines, we should
    -- still consider it one import group.
    separatedByBlank' a b =
      fromMaybe False $ do
        endA <- srcSpanEndLine <$> srcSpanToRealSrcSpan (getLocA a)
        startB <- srcSpanStartLine <$> srcSpanToRealSrcSpan (getLocA b)
        pure . any (not . hasComment) $ [endA + 1 .. startB - 1]

    -- Maps startLine -> endLine
    commentLineIntervals =
      Map.fromList
        [ (srcSpanStartLine spn, srcSpanEndLine spn)
        | comment <- opts.allComments,
          let spn = epaLocationRealSrcSpan $ getLoc comment
        ]
    hasComment lineNum =
      (not . Map.null)
        -- Find any comment where: startLine <= lineNum <= endLine
        . Map.filter (>= lineNum)
        . Map.takeWhileAntitone (<= lineNum)
        $ commentLineIntervals

groupImports :: forall x. GroupImportsOpts -> Set Cabal.ModuleName -> (x -> Import) -> [x] -> [[x]]
groupImports opts localModules fToImport = regroup . fmap (breakTies . matchRules)
  where
    ImportGroups igs = groupsFromConfig opts.grouping

    indexedGroupRules :: [(Int, [ImportGroupRule])]
    indexedGroupRules = zip [0 ..] (toList . igRules <$> toList igs)

    matchRules :: x -> ([(Int, [ImportGroupRule])], x)
    matchRules x =
      let imp = fToImport x
          testRule (_, rules) = any (matchesRule localModules imp) rules
       in (filter testRule indexedGroupRules, x)

    breakTies :: ([(Int, [ImportGroupRule])], x) -> (Int, x)
    breakTies ([], x) =
      (maxBound, x) -- Any non-matched imports will be in the last group
    breakTies (matches, x) =
      (fst . minimumBy (compare `on` snd) $ second (minimum . fmap igrPriority) <$> matches, x)

    regroup :: [(Int, x)] -> [[x]]
    regroup = fmap (fmap snd) . groupBy ((==) `on` fst) . sortOn fst