tilia-0.0.1.0: src/Tilia/Format.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Formatting a file, with everything the project can tell us about it.
module Tilia.Format
( FormatError (..),
describeFormatError,
formatErrorExitCode,
refused,
Session,
newSession,
fixityNotesOf,
formatSource,
)
where
import Control.Applicative ((<|>))
import Control.Monad (when)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.Except (ExceptT, runExceptT, throwE)
import Data.Choice (Choice, fromBool, isTrue)
import Data.Foldable (traverse_)
import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import GHC.LanguageExtensions.Type (Extension (ImplicitPrelude))
import Tilia.Cpp
( CppError (..),
blankCpp,
branchLeaves,
describeCppError,
formatWithCpp,
usesCpp,
withoutRuledOut,
)
import Tilia.Cpp.Macros (Macros)
import Tilia.Doc (defaultRenderOptions, printDoc)
import Tilia.Equivalence (commentDifference, syntaxDifference)
import Tilia.Fixity (OpName, Unknown (..), operatorSpelling, spellUnreadIn, unknownOperators)
import Tilia.Fixity.Debug (FixityNotes, fixityNotes)
import Tilia.Fixity.Plan
( PlanComponent,
Resolver (..),
loadPlan,
macrosOf,
newResolver,
scopeFor,
)
import Tilia.Package
( PackageProblem (..),
PackageReader,
describePackageProblem,
newPackageReader,
)
import Tilia.Palette (Color (Operator, Place), Palette, paint)
import Tilia.Parser
( ParseError,
ParsedModule,
ParserConfig,
describeParseError,
parseModule,
parserConfigFor,
pmModule,
pmSource,
)
import Tilia.Pragma (effectiveExtensions, movesPositions)
import Tilia.Project (ProjectRoot (..), findProjectRoot)
import Tilia.Render (RenderConfig (..), defaultRenderConfig, renderModule)
import Tilia.Source (comments)
-- | Why a file could not be formatted.
data FormatError
= -- | No @cabal.project@ or @.cabal@ file above it.
NoProject FilePath
| -- | A project, but no build plan we could read or produce. The text is
-- whatever @cabal@ had to say about it.
NoBuildPlan FilePath Text
| -- | We failed to read .cabal file.
NoPackage FilePath PackageProblem
| -- | The file is not Haskell we can parse.
NotParsed ParseError
| -- | The file carries @{-# LINE #-}@ or @{-# COLUMN #-}@ pragmas.
PositionPragmas FilePath
| -- | The file uses the preprocessor in a way we cannot handle.
CppUnsupported FilePath CppError
| -- | An operator the file uses has a fixity we could not establish, as
-- the file writes it.
UnknownFixity FilePath [((Maybe Text, OpName), Unknown)]
| -- | The file could not be read at all.
Unreadable FilePath Text
| -- | Formatting the file changed its AST.
NotEquivalent FilePath Text
| -- | Formatting is not idempotent.
NotIdempotent FilePath Text
-- | Say what went wrong, in one line.
describeFormatError :: Palette -> FormatError -> Text
describeFormatError palette = \case
NoProject path ->
"no project above " <> file path <> ": expected a cabal.project or a .cabal file"
NoBuildPlan root reason ->
"no build plan for " <> file root <> ": " <> reason
NoPackage path problem ->
"cannot tell what "
<> file path
<> " is written in: "
<> describePackageProblem problem
NotParsed e -> "cannot parse " <> located (describeParseError e)
PositionPragmas path ->
"will not format " <> file path <> ": it uses {-# LINE #-} pragmas, and no reformatting can leave those true"
CppUnsupported path why ->
"will not format " <> file path <> ": " <> describeCppError why
Unreadable path why -> "cannot read " <> file path <> ": " <> why
NotEquivalent path why ->
"formatting " <> file path <> " changed the program: " <> why
NotIdempotent path why ->
"formatting " <> file path <> " is not idempotent: " <> why
UnknownFixity path unknown ->
"will not format "
<> file path
<> ": "
<> T.intercalate ", and " (map saying (together unknown))
where
saying (why, ops) =
(if length ops == 1 then "the fixity of " else "the fixities of ")
<> listing ops
<> " "
<> because why
because = \case
NotRead missing -> "may be declared in " <> spellUnreadIn palette missing
Ambiguous -> "is declared differently by two modules in scope"
together = foldl put []
where
put seen ((qualifier, op), why) =
let named = paint palette Operator (operatorSpelling qualifier op)
in case break ((== why) . fst) seen of
(before, (_, ops) : after) ->
before <> [(why, ops <> [named])] <> after
_ -> seen <> [(why, [named])]
listing ops = case reverse ops of
[] -> ""
[one] -> one
[second, first'] -> first' <> " and " <> second
(final : rest) -> T.intercalate ", " (reverse rest) <> ", and " <> final
where
file = paint palette Place . T.pack
located t = case T.breakOn ":" t of
(where', rest) -> paint palette Place where' <> rest
-- | The exit status a failure should leave behind.
formatErrorExitCode :: FormatError -> Int
formatErrorExitCode = \case
NoProject {} -> 2
NoBuildPlan {} -> 3
NotParsed {} -> 4
PositionPragmas {} -> 5
NoPackage _ problem -> case problem of
NoPackageFile -> 6
PackageUnreadable {} -> 6
PackageMalformed {} -> 7
FileUnclaimed {} -> 8
UnknownFixity {} -> 15
Unreadable {} -> 16
NotEquivalent {} -> 17
NotIdempotent {} -> 18
CppUnsupported _ why -> case why of
UnhandledDirective {} -> 9
UnsplittableConditional -> 10
TooManyConfigurations -> 11
ConfigurationNotParsed {} -> 12
DirectiveUnplaceable {} -> 13
DirectiveInQuotedText {} -> 14
-- | Did we decline to format the file, rather than fail to?
refused :: FormatError -> Bool
refused = \case
PositionPragmas {} -> True
CppUnsupported {} -> True
UnknownFixity {} -> True
NotParsed {} -> False
NoPackage {} -> False
Unreadable {} -> False
NotEquivalent {} -> False
NotIdempotent {} -> False
NoProject {} -> False
NoBuildPlan {} -> False
-- | What a run works out once and then uses for every file.
--
-- Finding the project, solving its build plan and building a resolver cost
-- about as much as formatting a small file, and none of it depends on which
-- file is being formatted.
data Session = Session
{ -- | What can be asked about the modules a file imports.
sessionResolver :: Resolver,
-- | What the plan settles about the questions a file's conditionals
-- ask, so that a branch it rules out is not read as part of the file.
sessionMacros :: Macros,
-- | What each file's package puts in force.
sessionPackage :: PackageReader,
-- | Whether to check AST equivalence.
sessionCheckAst :: Choice "checkAst",
-- | Whether to check idempotence.
sessionCheckIdempotence :: Choice "checkIdempotence",
-- | An account of how each file's fixities were determined. 'Nothing'
-- when this information was not requested, which is what keeps an
-- ordinary run from doing any of the work.
sessionFixityNotes :: Maybe (IORef (Map FilePath FixityNotes))
}
-- | Settle everything that does not depend on the file being formatted.
newSession ::
-- | Where to start looking for the project
FilePath ->
-- | The components about to be formatted, so that a plan which says
-- nothing about them can be solved again rather than trusted
[PlanComponent] ->
-- | Check AST equivalence.
Choice "checkAst" ->
-- | Check idempotence.
Choice "checkIdempotence" ->
-- | Record how every file's fixities were settled, to be read afterwards
-- with 'fixityNotesOf'.
Choice "debugFixity" ->
IO (Either FormatError Session)
newSession start components checkAst checkIdempotence debugFixity = runExceptT $ do
root <- prPath <$> (need (NoProject start) =<< liftIO (findProjectRoot start))
plan <- orElse (NoBuildPlan root) =<< liftIO (loadPlan components root)
resolver <- liftIO (newResolver plan)
askPackage <- liftIO newPackageReader
notes <-
if isTrue debugFixity
then Just <$> liftIO (newIORef Map.empty)
else pure Nothing
pure
Session
{ sessionResolver = resolver,
sessionMacros = macrosOf plan,
sessionPackage = askPackage,
sessionCheckAst = checkAst,
sessionCheckIdempotence = checkIdempotence,
sessionFixityNotes = notes
}
where
need :: FormatError -> Maybe a -> ExceptT FormatError IO a
need e = maybe (throwE e) pure
-- | What the run made of every file's operators, by file.
--
-- Empty unless the session was asked to keep an account of it. Nothing here
-- is rendered; 'Tilia.Fixity.Debug.renderFixityNotes' does that.
fixityNotesOf :: Session -> IO (Map FilePath FixityNotes)
fixityNotesOf session = case sessionFixityNotes session of
Nothing -> pure Map.empty
Just ref -> readIORef ref
-- | Format source that has already been read.
--
-- The text is passed in rather than read here because a caller that means
-- to compare the two needs the original anyway, and reading a file twice to
-- format it once is the sort of thing this is trying to stop doing.
formatSource ::
-- | What the run has worked out already
Session ->
-- | The file the source came from, for reporting and for its package
FilePath ->
-- | The source
Text ->
-- | Result
IO (Either FormatError Text)
formatSource session path source = runExceptT $ do
when (movesPositions source) $
throwE (PositionPragmas path)
package <- orElse (NoPackage path) =<< liftIO (sessionPackage session path)
let resolver = sessionResolver session
config = parserConfigFor package
reading = blankCpp . withoutRuledOut (sessionMacros session)
extensionsAndCpp text =
let declared = effectiveExtensions package text
in (Set.fromList declared, usesCpp declared text)
renderConfigFor extensions hsModule = do
let implicitPrelude =
fromBool (Set.member ImplicitPrelude extensions)
scope <- liftIO (scopeFor resolver implicitPrelude hsModule)
liftIO $ case sessionFixityNotes session of
Nothing -> pure ()
Just ref -> do
told <-
fixityNotes
implicitPrelude
(askFixities resolver)
(askChain resolver)
scope
hsModule
atomicModifyIORef' ref (\m -> (Map.insertWith (\_ old -> old) path told m, ()))
case unknownOperators scope hsModule of
[] ->
pure
defaultRenderConfig
{ rcExtensions = extensions,
rcScope = Just scope
}
unknown -> throwE (UnknownFixity path unknown)
formatting (extensions, cpp) already text
| cpp = do
render <- case parseModule config path (reading text) of
Left _ -> pure defaultRenderConfig {rcExtensions = extensions}
Right whole -> renderConfigFor extensions (pmModule whole)
printed <-
orElse
(CppUnsupported path)
(formatWithCpp config render path text)
pure (printed, Nothing)
| otherwise = do
parsed <-
maybe (orElse NotParsed (parseModule config path text)) pure already
render <- renderConfigFor extensions (pmModule parsed)
pure
( printDoc defaultRenderOptions (renderModule render parsed),
Just parsed
)
let inForce@(_, cpp) = extensionsAndCpp source
(formatted, tree) <- formatting inForce Nothing source
printedTree <-
if isTrue (sessionCheckAst session)
then do
let (changed, parsed) = rewritten config cpp path (source, tree) formatted
traverse_ (throwE . NotEquivalent path) changed
pure parsed
else pure Nothing
when (isTrue (sessionCheckIdempotence session)) $ do
(settled, _) <- formatting (extensionsAndCpp formatted) printedTree formatted
when (settled /= formatted) $
throwE (NotIdempotent path (whereTheyDiffer formatted settled))
pure formatted
-- | Where two spellings of the same file first disagree.
whereTheyDiffer :: Text -> Text -> Text
whereTheyDiffer before after =
case [n | (n, one, two) <- zip3 [1 :: Int ..] first second, one /= two] of
(n : _) -> "line " <> tshow n <> " differs"
[] ->
"the second pass came out "
<> tshow (length second)
<> " lines long where the first came out "
<> tshow (length first)
where
first = T.lines before
second = T.lines after
tshow :: Int -> Text
tshow = T.pack . show
-- | What formatting changed about the program.
--
-- A file with conditionals is compared one configuration at a time, because
-- the text as it stands is not a program: the branches only make one once
-- the preprocessor has chosen between them.
rewritten ::
-- | How to parse both sides
ParserConfig ->
-- | Whether the file uses the preprocessor
Bool ->
-- | The file, for the parser's messages
FilePath ->
-- | What was read, and the tree it was printed from where it has one
(Text, Maybe ParsedModule) ->
-- | What was printed
Text ->
-- | What formatting changed, and the tree of what was printed
(Maybe Text, Maybe ParsedModule)
rewritten config cpp path (before, printedFrom') after
| not cpp = case parseModule config path after of
Left e ->
( Just ("the formatted output does not parse: " <> describeParseError e),
Nothing
)
Right a' -> case printedFrom' <|> whatParsed (parseModule config path before) of
-- The input parsed once already, or there would be nothing to
-- compare.
Nothing -> (Nothing, Just a')
Just b' -> (comparing b' a', Just a')
| otherwise = (underCpp, Nothing)
where
comparing b' a' =
syntaxDifference (pmModule b') (pmModule a')
<|> commentDifference
(pmModule b', pmModule a')
(comments (pmSource b'))
(comments (pmSource a'))
whatParsed = either (const Nothing) Just
underCpp = case (branchLeaves before, branchLeaves after) of
-- Neither can really happen: a source that would not split never got
-- as far as being formatted. Saying so beats saying nothing.
(Left _, _) -> Just "the input could not be split into configurations"
(_, Left _) -> Just "the output could not be split into configurations"
(Right went, Right came)
| length went /= length came ->
Just
( "the output has "
<> tshow (length came)
<> " configurations where the input had "
<> tshow (length went)
)
| otherwise ->
firstJust
[ ("in one configuration, " <>) <$> difference b a
| (b, a) <- zip went came
]
difference b a = case (parseModule config path b, parseModule config path a) of
-- The input parsed once already, or there would be nothing to compare.
(Left _, _) -> Nothing
(_, Left e) ->
Just ("the formatted output does not parse: " <> describeParseError e)
(Right b', Right a') -> comparing b' a'
firstJust = foldr (<|>) Nothing
tshow :: Int -> Text
tshow = T.pack . show
-- | Give up with the given error where there is one to give up over.
orElse :: (e -> FormatError) -> Either e a -> ExceptT FormatError IO a
orElse f = either (throwE . f) pure