packages feed

cabal-fix-0.1.0.0: src/CabalFix.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedLabels #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-}
{-# OPTIONS_GHC -Wno-orphans #-}
{-# OPTIONS_GHC -Wno-x-partial #-}

-- | Tools to print, parse and fix cabal files.
module CabalFix
  ( -- * Usage
    -- $usage

    -- * Configuration
    Config (..),
    defaultConfig,
    AddPolicy (..),
    CommaStyle (..),
    CommaTrail (..),
    DepAlignment (..),
    ValueAlignment (..),
    Margin (..),

    -- * CabalFields
    Comment,
    CabalFields (..),
    cabalFields',
    fieldList',

    -- * Lenses
    -- $lenses
    topfield',
    field',
    subfield',
    section',
    secFields',
    fieldOrSection',
    overField,
    overFields,
    pname,
    fieldLines',
    fieldName',
    secArgs',
    secArgBS',
    fieldLine',
    fieldValues',

    -- * Parsing
    parseCabalFields,

    -- * Printing
    printCabalFields,

    -- * Fixes
    fixCabalFields,
    fixCabalFile,
    fixesCommas,
    addsFields,
    addField,
    fixBuildDeps,

    -- * Dependency
    Dep (..),

    -- * Examples
    minimalExampleBS,
    minimalConfig,
  )
where

import CabalFix.FlatParse (depP, runParserEither)
import Control.Category ((>>>))
import Control.Monad
import Data.Bifunctor
import Data.Bool
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Char8 qualified as C
import Data.Foldable
import Data.Function
import Data.Functor.Identity
import Data.List qualified as List
import Data.Map.Strict qualified as Map
import Data.Maybe
import Data.String.Interpolate
import Data.TreeDiff hiding (FieldName)
import Data.TreeDiff.OMap qualified as OMap
import Data.Vector qualified as V
import Distribution.Fields
import Distribution.Fields.Field
import Distribution.Parsec
import Distribution.Pretty
import Distribution.Utils.Generic
import Distribution.Version
import GHC.Generics hiding (to)
import Optics.Extra
import Text.PrettyPrint qualified as PP
import Prelude

-- $setup
--
-- >>> :set -XOverloadedStrings
-- >>> :set -XOverloadedLabels
-- >>> :set -Wno-x-partial
-- >>> import CabalFix
-- >>> import Optics.Extra
-- >>> import Data.ByteString.Char8 qualified as C
-- >>> import CabalFix.Patch
-- >>> bs = minimalExampleBS
-- >>> cfg = defaultConfig
-- >>> (Just cf) = preview (cabalFields' cfg) bs
-- >>> fs = cf & view (#fields % fieldList')
-- >>> printCabalFields cfg (cf & over (#fields % fieldList') (take 4)) & C.putStr
-- cabal-version: 3.0
-- name: minimal
-- version: 0.1.0.0
-- license: BSD-2-Clause

-- $usage
--
-- >>> :set -XOverloadedStrings
-- >>> :set -XOverloadedLabels
-- >>> import CabalFix
-- >>> import Optics.Extra
-- >>> import Data.ByteString.Char8 qualified as C
-- >>> import CabalFix.Patch
-- >>> bs = minimalExampleBS
-- >>> cfg = defaultConfig
-- >>> (Just cf) = preview (cabalFields' cfg) bs
-- >>> fs = cf & view (#fields % fieldList')
-- >>> printCabalFields cfg (cf & over (#fields % fieldList') (take 4)) & C.putStr
-- cabal-version: 3.0
-- name: minimal
-- version: 0.1.0.0
-- license: BSD-2-Clause

-- | Configuration values for various aspects of (re)rendering a cabal file.
data Config = Config
  { -- | fields that should be converted to free text
    freeTexts :: [ByteString],
    -- | fields that should be removed
    fieldRemovals :: [ByteString],
    -- | Preferred dependency ranges
    preferredDeps :: [(ByteString, ByteString)],
    -- | Add fields (Overwriting depends on an 'AddPolicy')
    addFields :: [(ByteString, ByteString, AddPolicy)],
    -- | Fields where CommaStyle should be checked and fixed.
    fixCommas :: [(ByteString, CommaStyle, CommaTrail)],
    -- | Fields where elements should be sorted alphabetically
    sortFieldLines :: [ByteString],
    -- | Whether to sort Fields.
    doSortFields :: Bool,
    -- | The preferred ordering of Fields if they are sorted (lower numbers are placed first).
    fieldOrdering :: [(ByteString, Double)],
    -- | Whether to fix the build dependency Field
    doFixBuildDeps :: Bool,
    -- | How to align build dependencies
    depAlignment :: DepAlignment,
    -- | Whether to remove Fields with no information
    removeBlankFields :: Bool,
    -- | Whether to column-align values
    valueAligned :: ValueAlignment,
    -- | The number of spaces between the field nameand the value, if aligned.
    valueAlignGap :: Int,
    -- | Margin between sections
    sectionMargin :: Margin,
    -- | Margin around comments
    commentMargin :: Margin,
    -- | Shift from narrow style to multi-line beyond this column size.
    narrowN :: Int,
    -- | Indentation value
    indentN :: Int
  }
  deriving (Eq, Show, Read, Generic)

-- | An opinionated configuration for formatting cabal files.
--
-- Some opinions (that can be configured):
--
-- >>> fixCommas defaultConfig
-- [("extra-doc-files",NoCommas,NoTrailer),("build-depends",PrefixCommas,Trailer)]
--
-- 'PrefixCommas' are better for the dependency list as dependency ranges are already noisy enough without a comma thrown in. 'Trailer' (which means leading comma for prefixed commas) is neater and easier to prepend to, append to & sort.
--
-- If a field list doesn't need commas, then they should be removed.
--
-- >>> preferredDeps defaultConfig
-- [("base",">=4.14 && <5")]
--
-- Standard practice compared with the much tighter eg @base ^>=4.17.2.1@
--
-- >>> sortFieldLines defaultConfig
-- ["build-depends","exposed-modules","default-extensions","ghc-options","extra-doc-files","tested-with"]
--
-- Sort all the things, but especially the module list.
--
-- >>> valueAligned defaultConfig
-- ValueUnaligned
--
-- Adding an extra, long-named field to the cabal file means we have to re-align all the value parts in all the other fields.
--
-- >>> depAlignment defaultConfig
-- DepAligned
--
-- build-depends is so busy, however, the extra alignment becomes more important.
--
-- >>> doSortFields defaultConfig
-- True
--
-- Whatever the order, fields should have the same order within each section.
defaultConfig :: Config
defaultConfig =
  Config
    ["description"]
    []
    defaultPreferredDeps
    []
    defaultFixCommas
    defaultFieldLineSorts
    True
    defaultFieldOrdering
    True
    DepAligned
    True
    ValueUnaligned
    1
    Margin
    NoMargin
    60
    4

-- | The style for comma-separated values
data CommaStyle
  = -- | commas before values
    PrefixCommas
  | -- | commas after values
    PostfixCommas
  | -- | comma freedom
    FreeformCommas
  | -- | remove commas (allowed for some fields)
    NoCommas
  deriving (Eq, Show, Read, Generic)

-- | Include a trailing (or leading) comma, after the last value (or before the first value.)
data CommaTrail
  = Trailer
  | NoTrailer
  deriving (Eq, Show, Read, Generic)

-- | Policy for Fields listed in 'addFields'
data AddPolicy
  = -- | Replace existing values
    AddReplace
  | -- | Append after existing values
    AddAppend
  | -- | Add only of the Field doesn't exist
    AddIfNotExisting
  deriving (Eq, Show, Read, Generic)

defaultFixCommas :: [(ByteString, CommaStyle, CommaTrail)]
defaultFixCommas =
  [ ("extra-doc-files", NoCommas, NoTrailer),
    ("build-depends", PrefixCommas, Trailer)
  ]

-- | An opionated ordering of fields.
defaultFieldOrdering :: [(ByteString, Double)]
defaultFieldOrdering = [("cabal-version", 0), ("import", 1), ("main-is", 2), ("default-language", 3), ("name", 4), ("hs-source-dirs", 5), ("version", 6), ("build-depends", 7), ("exposed-modules", 8), ("license", 9), ("license-file", 10), ("other-modules", 11), ("copyright", 12), ("category", 13), ("author", 14), ("default-extensions", 15), ("ghc-options", 16), ("maintainer", 17), ("homepage", 18), ("bug-reports", 19), ("synopsis", 20), ("description", 21), ("build-type", 22), ("tested-with", 23), ("extra-doc-files", 24), ("source-repository", 25), ("type", 26), ("common", 27), ("location", 28), ("library", 29), ("executable", 30), ("test-suite", 31)]

-- An opinionated list of fields whose elements should be sorted.
defaultFieldLineSorts :: [ByteString]
defaultFieldLineSorts =
  [ "build-depends",
    "exposed-modules",
    "default-extensions",
    "ghc-options",
    "extra-doc-files",
    "tested-with"
  ]

-- An opinionated list of preferred builddeps:
--
defaultPreferredDeps :: [(ByteString, ByteString)]
defaultPreferredDeps = [("base", ">=4.14 && <5")]

-- | Whether the value part of each field should be vertically aligned on a column.
data ValueAlignment = ValueAligned | ValueUnaligned deriving (Eq, Show, Read, Generic)

-- | Whether the range part of the dependency list should be vertically aligned on a column.
data DepAlignment = DepAligned | DepUnaligned deriving (Eq, Show, Read)

-- | A margin tracker for combining sections.
data Margin = Margin | NoMargin
  deriving (Eq, Show, Read, Generic)

-- | Collapse margins, any margin = margin
instance Semigroup Margin where
  NoMargin <> NoMargin = NoMargin
  _ <> _ = Margin

-- | Note that cabal does not have multi-line comments
type Comment = [ByteString]

-- | 'Field' list annotated with a 'Comment'
--
-- Note that this type does not contain any position information.
--
-- The construction assumes that comments relate to fields below, so there is potential for an end comment unrelated to any particular field.
data CabalFields = CabalFields {fields :: V.Vector (Field Comment), endComment :: Comment} deriving (Generic, Eq, Show)

instance Semigroup CabalFields where
  (CabalFields fs ec) <> (CabalFields fs' ec') = CabalFields (fs <> fs') (ec <> ec')

instance Monoid CabalFields where
  mempty = CabalFields V.empty []

-- | iso to flip between vectors and lists easily.
--
-- >>> cf & view (#fields % fieldList') & take 2
-- [Field (Name [] "cabal-version") [FieldLine [] "3.0"],Field (Name [] "name") [FieldLine [] "minimal"]]
fieldList' :: Iso' (V.Vector (Field Comment)) [Field Comment]
fieldList' = iso V.toList V.fromList

instance ToExpr (FieldLine Comment) where
  toExpr fl = Rec "FieldLine" (OMap.fromList [("comment", toExpr (fieldLineAnn fl)), ("fieldline", toExpr (fieldLineBS fl))])

instance ToExpr (Name Comment) where
  toExpr n = Rec "Name" (OMap.fromList [("comment", toExpr (nameAnn n)), ("name", toExpr (getName n))])

instance ToExpr (SectionArg Comment) where
  toExpr (SecArgName c bs) = Rec "SecArgName" (OMap.fromList [("comment", toExpr c), ("arg", toExpr bs)])
  toExpr (SecArgStr c bs) = Rec "SecArgStr" (OMap.fromList [("comment", toExpr c), ("arg", toExpr bs)])
  toExpr (SecArgOther c bs) = Rec "SecArgOther" (OMap.fromList [("comment", toExpr c), ("arg", toExpr bs)])

instance ToExpr (Field Comment) where
  toExpr (Field n fls) = Rec "Field" (OMap.fromList [("name", toExpr n), ("field lines", toExpr fls)])
  toExpr (Section n ss fs) = Rec "Section" (OMap.fromList [("name", toExpr n), ("section args", toExpr ss), ("fields", toExpr fs)])

instance ToExpr CabalFields where
  toExpr cf = Rec "CabalFields" (OMap.fromList [("fields", toExpr $ fields cf), ("extras", toExpr $ endComment cf)])

-- | A Prism betwixt a 'ByteString' and a 'CabalFields'.
--
-- >>> cf & over (#fields % fieldList') (take 2) & review (cabalFields' cfg) & C.putStr
-- cabal-version: 3.0
-- name: minimal
cabalFields' :: Config -> Prism' ByteString CabalFields
cabalFields' cfg = prism (printCabalFields cfg) (parseCabalFields cfg)

-- $lenses
--
-- Lensing into 'Field' is tricky.
--
-- A 'Field' is a sum type of a field constructor or a section constructor, and a section contains fields.
--
-- Sometimes you only want to modify a field (and not a section). Other times you want to access a section but not a field. Sometimes you want to modify either a field or a section, and the fields within sections. It can be difficult to remember which lens is which.
--
-- The use of a list is also problematic; it is hard to safely delete a field, and invalid cabals are easily represented. A list can easily contain two name fields say, which is an invalid state. It can contain no name which is also invalid. It is difficult, however, to switch to a map because sections contain lists of fields (and not maps of fields).
--
-- Most useful are lenses that lens into named fields.

-- | A lens that doesn't descend into sections. It will lens the first-encountered named field, if any.
--
-- >>> view (topfield' "name") cf
-- Just (Field (Name [] "name") [FieldLine [] "minimal"])
--
-- >>> view (topfield' "build-depends") cf
-- Nothing
topfield' :: FieldName -> Lens' CabalFields (Maybe (Field Comment))
topfield' name = lens (view (#fields % fieldList' % field' name) >>> listToMaybe) (fieldSet name)

fieldSet :: FieldName -> CabalFields -> Maybe (Field Comment) -> CabalFields
fieldSet name cf f =
  case V.findIndex ((== name) . getName . fieldName) (view #fields cf) of
    Just i' -> case f of
      Nothing -> cf & over #fields (\v -> V.take i' v <> V.drop (i' + 1) v)
      Just f' -> cf & over #fields (\v -> V.take i' v <> V.singleton f' <> V.drop (i' + 1) v)
    Nothing -> cf & maybe id (over #fields . (\x -> (<> V.singleton x))) f

-- | A lens by name into a field (but not a section).
--
-- >>> fs & view (field' "version")
-- [Field (Name [] "version") [FieldLine [] "0.1.0.0"]]
field' :: FieldName -> Getter [Field Comment] [Field Comment]
field' name = to (filter (not . isSection) . filter (isName name))

-- | A getter by name into a field (including within sections)
--
-- >>> fs & toListOf (each % subfield' "default-language")
-- [[],[],[],[],[],[],[],[],[Field (Name [] "default-language") [FieldLine [] "GHC2021"]],[Field (Name [] "default-language") [FieldLine [] "GHC2021"]]]
subfield' :: FieldName -> Getter (Field Comment) [Field Comment]
subfield' name = to (subfield_ name)

subfield_ :: FieldName -> Field ann -> [Field ann]
subfield_ name f = filter (isName name) $ fieldUniverse f

-- | A getter of a section (not a field)
--
-- >>> fs & foldOf (section' "library" % each % secFields' % field' "exposed-modules")
-- [Field (Name [] "exposed-modules") [FieldLine [] "MyLib"]]
section' :: FieldName -> Getter [Field ann] [Field ann]
section' name = to (filter (\f -> isName name f && isSection f))

-- | A getter of section fields
secFields' :: Lens' (Field ann) [Field ann]
secFields' = lens secFieldsView secFieldsSet

secFieldsSet :: Field ann -> [Field ann] -> Field ann
secFieldsSet f@(Field {}) _ = f
secFieldsSet (Section n sa _) fs = Section n sa fs

secFieldsView :: Field ann -> [Field ann]
secFieldsView (Field {}) = []
secFieldsView (Section _ _ fs) = fs

-- | A getter by name of a field or section.
fieldOrSection' :: FieldName -> Getter [Field ann] [Field ann]
fieldOrSection' name = to (filter (isName name))

isName :: FieldName -> Field ann -> Bool
isName name = (== name) . view fieldName'

isSection :: Field ann -> Bool
isSection (Section {}) = True
isSection (Field {}) = False

-- | A mapping into the field structure, operating on field lists in sections as well as the field itself.
overField :: (Field ann -> Field ann) -> Field ann -> Field ann
overField f' f@(Field {}) = f' f
overField f' (Section n sa fs) = Section n sa (fmap (overField f') fs)

-- | A mapping into the field structure, operating on field lists in sections as well as field lists themselves.
overFields :: ([Field ann] -> [Field ann]) -> [Field ann] -> [Field ann]
overFields f fs = f $ fmap inner fs
  where
    inner f'@(Field {}) = f'
    inner (Section n sa fs') = Section n sa (overFields f fs')

-- | Project name. Errors if the field is missing.
--
-- >>> pname cf
-- "minimal"
pname :: CabalFields -> ByteString
pname cf = cf & preview (topfield' "name" % _Just % fieldLines' % ix 0 % to fieldLineBS) & fromMaybe (error "no name field")

-- | Name of (field or section).
--
-- >>> head fs & view fieldName'
-- "cabal-version"
fieldName' :: Lens' (Field ann) ByteString
fieldName' = lens (fieldName >>> getName) fieldNameSet
  where
    fieldNameSet (Field (Name ann _) fls) name = Field (Name ann name) fls
    fieldNameSet (Section (Name ann _) sa fs) name = Section (Name ann name) sa fs

inNameList :: [ByteString] -> Field ann -> Bool
inNameList ns f = view fieldName' f `elem` ns

-- | Lens into field lines
--
-- >>> fs & foldOf (section' "test-suite" % each % secFields' % field' "build-depends" % each % fieldLines')
-- [FieldLine [] "base ^>=4.17.2.1,",FieldLine [] "minimal"]
fieldLines' :: Lens' (Field ann) [FieldLine ann]
fieldLines' = lens fieldFieldLinesView fieldFieldLinesSet

fieldFieldLinesView :: Field ann -> [FieldLine ann]
fieldFieldLinesView (Field _ fls) = fls
fieldFieldLinesView _ = []

fieldFieldLinesSet :: Field ann -> [FieldLine ann] -> Field ann
fieldFieldLinesSet (Field n _) fls = Field n fls
fieldFieldLinesSet _ _ = error "setting a section field line"

-- * SectionArg

-- | lens into SectionArg part of a section.
--
-- Errors if you actually have a field.
--
-- >>> fs & foldOf (section' "test-suite" % each % secArgs')
-- [SecArgName [] "minimal-test"]
secArgs' :: Lens' (Field ann) [SectionArg ann]
secArgs' = lens secArgView secArgSet

secArgView :: Field ann -> [SectionArg ann]
secArgView (Field {}) = error "not a section"
secArgView (Section _ a _) = a

secArgSet :: Field ann -> [SectionArg ann] -> Field ann
secArgSet (Field {}) _ = error "not a section"
secArgSet (Section n _ fs) a = Section n a fs

-- | secArg lens into a ByteString representation
--
-- >>> fs & foldOf (section' "test-suite" % each % secArgs' % each % secArgBS')
-- ("name","minimal-test")
secArgBS' :: Lens' (SectionArg ann) (ByteString, ByteString)
secArgBS' = lens secArgBSView secArgBSSet

secArgBSView :: SectionArg a -> (ByteString, ByteString)
secArgBSView (SecArgName _ n) = ("name", n)
secArgBSView (SecArgStr _ n) = ("str", n)
secArgBSView (SecArgOther _ n) = ("other", n)

secArgBSSet :: SectionArg ann -> (ByteString, ByteString) -> SectionArg ann
secArgBSSet sa (t, a) = case t of
  "name" -> SecArgName (sectionArgAnn sa) a
  "str" -> SecArgStr (sectionArgAnn sa) a
  _ -> SecArgOther (sectionArgAnn sa) a

-- | lens into field line contents.
--
-- >>>  fs & toListOf (section' "test-suite" % each % secFields' % field' "build-depends" % each % fieldLines' % each % fieldLine')
-- ["base ^>=4.17.2.1,","minimal"]
fieldLine' :: Lens' (FieldLine ann) ByteString
fieldLine' = lens fieldLineBS setValueFL
  where
    setValueFL (FieldLine ann _) = FieldLine ann

-- | A fold of a field list into a ByteString.
fieldValues' :: FieldName -> Optic A_Fold '[Int, Int] [Field Comment] [Field Comment] ByteString ByteString
fieldValues' name = field' name % each % fieldLines' % each % fieldLine'

-- * fixes

-- | fix order:
--
-- - removes fields
--
-- - removes blank fields
--
-- - fixes commas
--
-- - adds Fields
--
-- - fix build dependencies
--
-- - sort field lines
--
-- - sort fields
fixCabalFields :: Config -> CabalFields -> CabalFields
fixCabalFields cfg cf =
  cf
    & over
      (#fields % fieldList')
      ( overFields (filter (not . inNameList (fieldRemovals cfg)))
          >>> overFields (bool id (filter (not . isBlankField)) (removeBlankFields cfg))
          >>> fmap (overField (fixesCommas cfg))
          >>> addsFields cfg
          >>> bool id (fmap (overField (fixBuildDeps cfg (pname cf)))) (doFixBuildDeps cfg)
          >>> fmap (overField (sortFieldLinesFor (sortFieldLines cfg)))
          >>> bool id (overFields (sortFields cfg)) (doSortFields cfg)
      )

-- | Fix a cabal file in-place
fixCabalFile :: FilePath -> Config -> IO Bool
fixCabalFile fp cfg = do
  bs <- BS.readFile fp
  maybe
    (pure False)
    (\cf -> BS.writeFile fp (printCabalFields cfg (fixCabalFields cfg cf)) >> pure True)
    (preview (cabalFields' cfg) bs)

-- * blank field removal

-- | Is the field blank (including has no section arguments if a section)
isBlankField :: Field ann -> Bool
isBlankField (Field _ fs) = null fs
isBlankField (Section _ sas fss) = null fss && null sas

-- * commas

-- | Fix the comma usage in a field list
--
-- >>> fs & toListOf (section' "test-suite" % each % secFields' % field' "build-depends" % each) & fmap (fixesCommas cfg)
-- [Field (Name [] "build-depends") [FieldLine [] ", base ^>=4.17.2.1",FieldLine [] ", minimal"]]
fixesCommas :: Config -> Field ann -> Field ann
fixesCommas cfg x = foldl' (&) x $ fixCommas cfg & fmap (\(n, s, t) -> bool id (fixCommasF s t) ((== n) $ view fieldName' x))

addCommaBS :: CommaStyle -> CommaTrail -> [ByteString] -> [ByteString]
addCommaBS commaStyle trailStyle xs = case trailStyle of
  NoTrailer -> case commaStyle of
    PostfixCommas -> ((<> ",") <$> init xs) <> [last xs]
    PrefixCommas -> head xs : ((", " <>) <$> tail xs)
    -- since we don't know the prior comma strategy, we just guess here.
    FreeformCommas -> ((<> ",") <$> init xs) <> [last xs]
    NoCommas -> xs
  Trailer -> case commaStyle of
    PostfixCommas -> (<> ",") <$> xs
    PrefixCommas -> (", " <>) <$> xs
    -- since we don't know the prior comma strategy, we just guess here.
    FreeformCommas -> (<> ",") <$> xs
    NoCommas -> xs

stripCommaBS :: ByteString -> ByteString
stripCommaBS bs =
  C.stripPrefix ", " bs
    & fromMaybe
      ( C.stripPrefix "," bs
          & fromMaybe
            ( C.stripSuffix "," bs
                & fromMaybe bs
            )
      )

fixCommasF :: CommaStyle -> CommaTrail -> Field ann -> Field ann
fixCommasF s t f = fls'
  where
    fls = toListOf (fieldLines' % each % fieldLine') f
    fls' = set fieldLines' (zipWith (set fieldLine') (addCommaBS s t $ fmap stripCommaBS fls) (view fieldLines' f)) f

-- | add fields
--
-- >>> addsFields (cfg & set #addFields [("description", "added by addsFields", AddReplace)]) []
-- [Field (Name [] "description") [FieldLine [] "added by addsFields"]]
addsFields :: Config -> [Field Comment] -> [Field Comment]
addsFields cfg x = foldl' (&) x $ addFields cfg & fmap (\(n, v, p) -> addField p (Field (Name [] n) [FieldLine [] v]))

-- | Add a field according to an AddPolicy.
addField :: AddPolicy -> Field ann -> [Field ann] -> [Field ann]
addField p f fs = case p of
  AddReplace -> notsames <> [f]
  AddAppend -> fs <> [f]
  AddIfNotExisting -> bool fs (fs <> [f]) (null sames)
  where
    sames = filter ((view fieldName' f ==) . view fieldName') fs
    notsames = filter ((view fieldName' f /=) . view fieldName') fs

-- | Align dependencies (if depAlignment is DepAligned), remove ranges for any self-dependency, and substitute preferred dependency ranges.
--
-- >>> fs & toListOf (section' "test-suite" % each % secFields' % field' "build-depends" % each) & fmap (fixBuildDeps cfg "minimal")
-- [Field (Name [] "build-depends") [FieldLine [] ", base    >=4.14 && <5",FieldLine [] ", minimal"]]
fixBuildDeps :: Config -> FieldName -> Field ann -> Field ann
fixBuildDeps cfg pn f = overField (bool id (over fieldLines' (fixBDLines cfg pn)) (isName "build-depends" f)) f

fixBDLines :: Config -> ByteString -> [FieldLine ann] -> [FieldLine ann]
fixBDLines cfg libdep fls = fls'
  where
    align = depAlignment cfg
    deps = [x | (Right x) <- parseDepFL <$> fls]
    pds = addCommaBS commaStyle trailStyle $ printDepsPreferred cfg libdep align deps
    fls' = zipWith (set fieldLine') pds fls

    (commaStyle, trailStyle) =
      maybe
        (PostfixCommas, NoTrailer)
        (\(_, x, y) -> (x, y))
        (find ((== "build-depends") . (\(x, _, _) -> x)) (fixCommas cfg))

-- | Split of a dependency 'FieldLine' into the dependency name and the range.
data Dep = Dep {dep :: ByteString, depRange :: ByteString} deriving (Show, Ord, Eq, Generic)

normDepRange :: ByteString -> ByteString
normDepRange dr = (maybe dr (C.pack . show . pretty) . (simpleParsecBS :: ByteString -> Maybe VersionRange)) dr

printDepPreferred :: Config -> ByteString -> Int -> Dep -> ByteString
printDepPreferred cfg libd n (Dep d r) = C.intercalate (C.pack $ replicate n ' ') ([d] <> rs)
  where
    r' = bool (fromMaybe (normDepRange r) (Map.lookup d (Map.fromList (preferredDeps cfg)))) (normDepRange r) (libd == d)
    rs = bool [r'] [] (r' == "")

printDepsPreferred :: Config -> ByteString -> DepAlignment -> [Dep] -> [ByteString]
printDepsPreferred cfg libd DepUnaligned ds = printDepPreferred cfg libd 1 <$> ds
printDepsPreferred cfg libd DepAligned ds = zipWith (printDepPreferred cfg libd) ns ds
  where
    ls = BS.length . dep <$> ds
    ns = (\x -> maximum ls - x + 1) <$> ls

parseDepFL :: FieldLine ann -> Either ByteString Dep
parseDepFL fl = uncurry Dep <$> runParserEither depP (view fieldLine' fl)

-- | sort field lines for listed fields
sortFieldLinesFor :: [ByteString] -> Field ann -> Field ann
sortFieldLinesFor ns f@(Field n fl) =
  Field n (bool fl (List.sortOn fieldLineBS fl) (view fieldName' f `elem` ns))
sortFieldLinesFor ns (Section n a fss) = Section n a (sortFieldLinesFor ns <$> fss)

-- | sorting fields, based on fieldOrdering configuration.
--
-- A secondary ordering is based on the first fieldline (for fields) or section args (for sections).
sortFields :: Config -> [Field ann] -> [Field ann]
sortFields cfg fs = overFields (List.sortOn (\f -> (fromMaybe 100 (Map.lookup (view fieldName' f) (Map.fromList $ fieldOrdering cfg)), name2 f))) fs

name2 :: Field ann -> Maybe ByteString
name2 (Field _ fl) = listToMaybe (fieldLineBS <$> fl)
name2 (Section _ a _) = listToMaybe $ snd . view secArgBS' <$> a

-- | Printing
--
-- Convert a 'CabalFields' to a 'ByteString'
--
-- >>> printCabalFields cfg (cf & over (#fields % fieldList') (take 4)) & C.putStr
-- cabal-version: 3.0
-- name: minimal
-- version: 0.1.0.0
-- license: BSD-2-Clause
printCabalFields :: Config -> CabalFields -> ByteString
printCabalFields cfg cf =
  ( C.pack
      . showFieldsIndent cfg fComment (const id) (indentN cfg)
      . fmap (fmap (fmap C.unpack))
      . printFieldsComments
      $ view (#fields % fieldList') cf
  )
    <> C.unlines (view #endComment cf)
  where
    fComment [] = NoComment
    fComment xs = CommentBefore xs

printFieldsComments :: [Field [ByteString]] -> [PrettyField [ByteString]]
printFieldsComments =
  runIdentity
    . genericFromParsecFields
      (Identity .: prettyFieldLines)
      (Identity .: prettySectionArgs)
  where
    (.:) :: (a -> b) -> (c -> d -> a) -> (c -> d -> b)
    (f .: g) x y = f (g x y)

-- | Used in 'fromParsecFields'.
prettyFieldLines :: FieldName -> [FieldLine [ByteString]] -> PP.Doc
prettyFieldLines _ fls =
  PP.vcat $
    mconcat $
      [ PP.text . fromUTF8BS <$> cs <> [bs]
      | FieldLine cs bs <- fls
      ]

-- | Used in 'fromParsecFields'.
prettySectionArgs :: FieldName -> [SectionArg [ByteString]] -> [PP.Doc]
prettySectionArgs _ =
  fmap $
    mconcat . \case
      SecArgName cs bs -> showToken . fromUTF8BS <$> cs <> [bs]
      SecArgStr cs bs -> showToken . fromUTF8BS <$> cs <> [bs]
      SecArgOther cs bs -> PP.text . fromUTF8BS <$> cs <> [bs]

-- | 'showFields' with user specified indentation.
showFieldsIndent ::
  Config ->
  -- | Convert an annotation to lined to preceed the field or section.
  (ann -> CommentPosition) ->
  -- | Post-process non-annotation produced lines.
  (ann -> [String] -> [String]) ->
  -- | Indentation level.
  Int ->
  -- | Fields/sections to show.
  [PrettyField ann] ->
  String
showFieldsIndent cfg rann post n = unlines . renderFields cfg (Opts rann indent post)
  where
    -- few hardcoded, "unrolled"  variants.
    indent
      | n == 4 = indent4
      | n == 2 = indent2
      | otherwise = (replicate (max n 1) ' ' ++)

    indent4 :: String -> String
    indent4 [] = []
    indent4 xs = ' ' : ' ' : ' ' : ' ' : xs

    indent2 :: String -> String
    indent2 [] = []
    indent2 xs = ' ' : ' ' : xs

data Opts ann = Opts
  { _optAnnotation :: ann -> CommentPosition,
    _optIndent :: String -> String,
    _optPostprocess :: ann -> [String] -> [String]
  }

renderFields :: Config -> Opts ann -> [PrettyField ann] -> [String]
renderFields cfg opts fields = flattenBlocks blocks
  where
    len = maxNameLength 0 fields
    blocks =
      filter (not . null . _contentsBlock) $ -- empty blocks cause extra newlines #8236
        map (renderField cfg opts len) fields

    maxNameLength !acc [] = acc
    maxNameLength !acc (PrettyField _ name _ : rest) = maxNameLength (max acc (BS.length name)) rest
    maxNameLength !acc (PrettySection {} : rest) = maxNameLength acc rest
    maxNameLength !acc (PrettyEmpty : rest) = maxNameLength acc rest

-- | Block of lines with flags for optional blank lines before and after
data Block = Block
  { _beforeBlock :: Margin,
    _afterBlock :: Margin,
    _contentsBlock :: [String]
  }
  deriving (Show, Eq, Read, Generic)

flattenBlocks :: [Block] -> [String]
flattenBlocks = go0
  where
    go0 [] = []
    go0 (Block _before after strs : blocks) = strs ++ go after blocks

    go _surr' [] = []
    go surr' (Block before after strs : blocks) = ins $ strs ++ go after blocks
      where
        ins
          | surr' <> before == Margin = ("" :)
          | otherwise = id

lines_ :: String -> [String]
lines_ [] = []
lines_ s = lines s <> bool [] [""] ((== '\n') . last $ s)

renderField :: Config -> Opts ann -> Int -> PrettyField ann -> Block
renderField cfg (Opts rann indent post) fw (PrettyField ann name doc) =
  Block before after content
  where
    content = case comments of
      CommentBefore cs -> cs ++ post ann lines'
      CommentAfter cs -> post ann lines' ++ cs
      NoComment -> post ann lines'
    comments = rann ann
    before = case comments of
      CommentBefore [] -> NoMargin
      CommentAfter [] -> NoMargin
      NoComment -> NoMargin
      _ -> commentMargin cfg

    (lines', after) = case lines_ narrow of
      [] -> ([name' ++ ":"], NoMargin)
      [singleLine]
        | length singleLine < narrowN cfg ->
            ([name' ++ ": " ++ replicate (bool 0 (fw - length name' + (valueAlignGap cfg - 1)) (valueAligned cfg == ValueAligned)) ' ' ++ narrow], NoMargin)
      _ -> ((name' ++ ":") : map indent (lines_ (PP.render doc)), NoMargin)

    name' = fromUTF8BS name
    narrow = PP.renderStyle narrowStyle doc

    narrowStyle :: PP.Style
    narrowStyle = PP.style {PP.lineLength = PP.lineLength PP.style - fw}
renderField cfg opts@(Opts rann indent post) _ (PrettySection ann name args fields) =
  Block (sectionMargin cfg) (sectionMargin cfg) $
    attachComments
      (post ann [PP.render $ PP.hsep $ PP.text (fromUTF8BS name) : args])
      ++ map indent (renderFields cfg opts fields)
  where
    attachComments content = case rann ann of
      CommentBefore cs -> cs ++ content
      CommentAfter cs -> content ++ cs
      NoComment -> content
renderField _ _ _ PrettyEmpty = Block NoMargin NoMargin mempty

-- | Parse a 'ByteString' into a 'CabalFields'. Failure is possible.
--
-- >>> bs & C.lines & take 4 & C.unlines & parseCabalFields cfg
-- Right (CabalFields {fields = [Field (Name [] "cabal-version") [FieldLine [] "3.0"],Field (Name [] "name") [FieldLine [] "minimal"],Field (Name [] "version") [FieldLine [] "0.1.0.0"],Field (Name [] "license") [FieldLine [] "BSD-2-Clause"]], endComment = []})
parseCabalFields :: Config -> ByteString -> Either ByteString CabalFields
parseCabalFields cfg bs = case readFields bs of
  Left err -> Left $ C.pack (show err)
  Right fps ->
    (\(fs', ec) -> Right (CabalFields (V.fromList fs') ec)) $
      foldl' (&) (fmap (fmap (const [])) fs, []) (uncurry addComment <$> cfs)
    where
      fs = convertFreeTexts (view #freeTexts cfg) fps
      cs = extractComments bs
      pt = Map.toList $ makePositionTree fs
      cfs = fmap (first (fmap snd)) (first (fmap (pt List.!!) . (\x -> List.findIndex (\e -> fst e > x) pt)) <$> cs)

convertFreeText :: [ByteString] -> Field Position -> Field Position
convertFreeText freeTexts f@(Field n fls) = bool f (Field n (convertToFreeText fls)) (inNameList freeTexts f)
convertFreeText freeTexts (Section n a fss) = Section n a (convertFreeTexts freeTexts fss)

convertFreeTexts :: [ByteString] -> [Field Position] -> [Field Position]
convertFreeTexts freeTexts fs = snd $ foldl' step (Nothing, []) fs
  where
    step :: (Maybe (Field Position), [Field Position]) -> Field Position -> (Maybe (Field Position), [Field Position])
    step (Nothing, res) nextFP = case inNameList freeTexts nextFP of
      True -> (Just (convertFreeText freeTexts nextFP), res)
      False -> (Nothing, res <> [nextFP])
    step (Just freeFP, res) nextFP = case inNameList freeTexts nextFP of
      True -> (Just (convertFreeText freeTexts nextFP), res <> [freeFP'])
      False -> (Nothing, res <> [freeFP', nextFP])
      where
        (Field n fls) = freeFP
        c1 = firstCol nextFP
        c0 = fromMaybe c1 $ firstColFls freeFP
        (FieldLine ann fls') = fromMaybe (FieldLine (Position 0 0) "") (listToMaybe fls)
        freeFP' = Field n [FieldLine ann (fls' <> C.pack (replicate (c1 - c0 - length (C.lines fls')) '\n'))]

firstCol :: Field Position -> Int
firstCol (Field (Name (Position c _) _) _) = c
firstCol (Section (Name (Position c _) _) _ _) = c

firstColFls :: Field Position -> Maybe Int
firstColFls (Field _ []) = Nothing
firstColFls (Field _ ((FieldLine (Position c _) _) : _)) = Just c
firstColFls (Section {}) = error "no field lines in a section"

convertToFreeText :: [FieldLine Position] -> [FieldLine Position]
convertToFreeText [] = []
convertToFreeText ((FieldLine (Position r0 c0) bs0) : xs) = [FieldLine (Position r0 c0) x]
  where
    x = mconcat $ snd $ foldl' (\(r', xs') (FieldLine (Position r _) bs) -> (r, xs' <> replicate (r - r') "\n" <> [bs])) (r0, [bs0]) xs

extractComments :: BS.ByteString -> [(Int, Comment)]
extractComments = go . zip [1 ..] . map (BS.dropWhile isSpace8) . C.lines
  where
    go :: [(Int, BS.ByteString)] -> [(Int, Comment)]
    go [] = []
    go ((n, bs) : rest)
      | isComment bs = case span ((isComment .|| BS.null) . snd) rest of
          (h, t) -> (n, bs : map snd h) : go t
      | otherwise = go rest

    (f .|| g) x = f x || g x

    isSpace8 w = w == 9 || w == 32

    isComment :: BS.ByteString -> Bool
    isComment = BS.isPrefixOf "--"

data FieldPath
  = End
  | Nth Int FieldPath -- nth field
  deriving (Eq, Ord, Show)

makePositionTree :: [Field Position] -> Map.Map Int ([Int], String)
makePositionTree fs = foldFss Map.empty [] fs
  where
    foldFss m cursor fs' = fst $ foldl' stepFss (m, cursor <> [0]) fs'
    stepFss (m, cursor) (Field (Name (Position c _) _) fls) =
      (foldFls (Map.insertWith (\_ o -> o) c (cursor, "fieldname") m) cursor fls, inc cursor)
    stepFss (m, cursor) (Section (Name (Position c _) _) sas fss) =
      (foldFss (foldSas (Map.insertWith (\_ o -> o) c (cursor, "sectionname") m) cursor sas) cursor fss, inc cursor)
    foldFls m c fls = fst $ foldl' stepFls (m, c <> [0]) fls
    stepFls (m, cursor) (FieldLine (Position c _) _) = (Map.insertWith (\_ o -> o) c (cursor, "fieldline") m, inc cursor)
    foldSas m c sas = fst $ foldl' stepSas (m, c <> [0]) (sectionArgAnn <$> sas)
    stepSas (m, cursor) (Position c _) = (Map.insertWith (\_ o -> o) c (cursor, "sectionarg") m, inc cursor)

    inc :: [Int] -> [Int]
    inc [] = []
    inc xs = reverse (1 + last xs : drop 1 (reverse xs))

addComment :: Maybe ([Int], String) -> [ByteString] -> ([Field [ByteString]], [ByteString]) -> ([Field [ByteString]], [ByteString])
addComment Nothing cs (fs, extras) = (fs, extras <> cs)
addComment (Just (cursor, tag)) cs (fs, extras) = (addc cs cursor tag fs, extras)

addc :: [ByteString] -> [Int] -> String -> [Field [ByteString]] -> [Field [ByteString]]
addc _ [] _ fs = fs
addc comments [x] "fieldname" fs = take x fs <> [f'] <> drop (x + 1) fs
  where
    (Field (Name cs n) fls) = (List.!!) fs x
    f' = Field (Name (cs <> comments) n) fls
addc comments [x] "sectionname" fs = take x fs <> [f'] <> drop (x + 1) fs
  where
    (Section (Name cs n) a fss) = (List.!!) fs x
    f' = Section (Name (cs <> comments) n) a fss
addc comments [x, y] "fieldline" fs = take x fs <> [f'] <> drop (x + 1) fs
  where
    (Field n fls) = (List.!!) fs x
    (FieldLine cs bs) = (List.!!) fls y
    fl' = FieldLine (cs <> comments) bs
    f' = Field n (take y fls <> [fl'] <> drop (y + 1) fls)
addc comments [x, y] "sectionarg" fs = take x fs <> [f'] <> drop (x + 1) fs
  where
    (Section n sas fss) = (List.!!) fs x
    sa' = (<> comments) <$> (List.!!) sas y
    f' = Section n (take y sas <> [sa'] <> drop (y + 1) sas) fss
addc comments (x : xs) tag fs = take x fs <> [f'] <> drop (x + 1) fs
  where
    (Section n a fss) = (List.!!) fs x
    f' = Section n a (addc comments xs tag fss)

-- | Minimal cabal file contents for testing purposes. Originally created via:
--
-- > mkdir minimal && cd minimal && cabal init --minimal --simple --overwrite --lib --tests --language=GHC2021 --license=BSD-2-Clause  -p minimal
minimalExampleBS :: ByteString
minimalExampleBS =
  [i|cabal-version:   3.0
name:            minimal
version:         0.1.0.0
license:         BSD-2-Clause
license-file:    LICENSE
build-type:      Simple
extra-doc-files: CHANGELOG.md

common warnings
    ghc-options: -Wall

library
    import:           warnings
    exposed-modules:  MyLib
    build-depends:    base ^>=4.17.2.1
    hs-source-dirs:   src
    default-language: GHC2021

test-suite minimal-test
    import:           warnings
    default-language: GHC2021
    type:             exitcode-stdio-1.0
    hs-source-dirs:   test
    main-is:          Main.hs
    build-depends:
        base ^>=4.17.2.1,
        minimal|]

-- | A config close to the @cabal init@ styles.
minimalConfig :: Config
minimalConfig =
  Config
    { freeTexts = ["description"],
      fieldRemovals = [],
      preferredDeps =
        [ ( "base",
            ">=4.14 && <5"
          )
        ],
      addFields = [],
      fixCommas =
        [ ( "extra-doc-files",
            NoCommas,
            NoTrailer
          ),
          ( "build-depends",
            PostfixCommas,
            NoTrailer
          )
        ],
      sortFieldLines =
        [ "build-depends",
          "exposed-modules",
          "default-extensions",
          "ghc-options",
          "extra-doc-files",
          "tested-with"
        ],
      doSortFields = True,
      fieldOrdering =
        [ ( "cabal-version",
            0.0
          ),
          ( "import",
            1.0
          ),
          ( "main-is",
            2.0
          ),
          ( "default-language",
            8.6
          ),
          ( "name",
            4.0
          ),
          ( "hs-source-dirs",
            8.4
          ),
          ( "version",
            6.0
          ),
          ( "build-depends",
            8.2
          ),
          ( "exposed-modules",
            8.0
          ),
          ( "license",
            9.0
          ),
          ( "license-file",
            10.0
          ),
          ( "other-modules",
            11.0
          ),
          ( "copyright",
            12.0
          ),
          ( "category",
            13.0
          ),
          ( "author",
            14.0
          ),
          ( "default-extensions",
            15.0
          ),
          ( "ghc-options",
            16.0
          ),
          ( "maintainer",
            17.0
          ),
          ( "homepage",
            18.0
          ),
          ( "bug-reports",
            19.0
          ),
          ( "synopsis",
            20.0
          ),
          ( "description",
            21.0
          ),
          ( "build-type",
            22.0
          ),
          ( "tested-with",
            23.0
          ),
          ( "extra-doc-files",
            24.0
          ),
          ( "source-repository",
            25.0
          ),
          ( "type",
            26.0
          ),
          ( "common",
            27.0
          ),
          ( "location",
            28.0
          ),
          ( "library",
            29.0
          ),
          ( "executable",
            30.0
          ),
          ( "test-suite",
            31.0
          )
        ],
      doFixBuildDeps = True,
      depAlignment = DepAligned,
      removeBlankFields = True,
      valueAligned = ValueAligned,
      valueAlignGap = 1,
      sectionMargin = Margin,
      commentMargin = Margin,
      narrowN = 60,
      indentN = 4
    }