tilia-0.1.0.0: tests/Tilia/RunSpec.hs
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Running the formatter over a set of files.
module Tilia.RunSpec (spec) where
import Control.Concurrent (getNumCapabilities, threadDelay)
import Data.ByteString qualified as BS
import Data.Either (isLeft)
import Data.IORef
import Data.List.NonEmpty (NonEmpty ((:|)))
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as T
import Data.Text.IO qualified as T
import GHC.Clock (getMonotonicTime)
import System.Directory (getModificationTime)
import System.FilePath ((</>))
import System.IO.Temp (withSystemTempDirectory)
import Test.Hspec
import Tilia.Cabal.Package (PackageProblem (..))
import Tilia.Cpp (CppError (..))
import Tilia.Fixity
( Direction (..),
Fixity (..),
ModuleChain (..),
OpName (..),
Unknown (..),
)
import Tilia.Format (FormatError (..), formatErrorExitCode, refused)
import Tilia.Palette (Color (Bad), Palette (..))
import Tilia.Run
import Tilia.Utils (lineWidth, visibleLength, wrapTo)
spec :: Spec
spec = do
describe "telling a refusal from a failure" $ do
it "counts what we would not touch as a refusal" $
fmap
refused
[ PositionPragmas "A.hs",
CppUnsupported "A.hs" TooManyConfigurations,
UnknownFixity "A.hs" []
]
`shouldBe` [True, True, True]
it "counts what we could not read or make sense of as a failure" $
fmap
refused
[ Unreadable "A.hs" "no such file",
NoPackage "A.hs" NoPackageFile,
NoProject "A.hs",
NoBuildPlan "." "cabal said no",
NotEquivalent "A.hs" "f = 1 became f = 2",
NotIdempotent "A.hs" "line 12 differs"
]
`shouldBe` [False, False, False, False, False, False]
describe "what became of a file" $ do
it "counts a rewrite as a difference and nothing else" $
fmap differs [Changed "a" "b", Unchanged, decline, failure]
`shouldBe` [True, False, False, False]
it "keeps refusals and failures apart" $ do
fmap declined [decline, failure, Unchanged] `shouldBe` [True, False, False]
fmap failed [decline, failure, Unchanged] `shouldBe` [False, True, False]
describe "the status a run leaves behind" $ do
it "has none to give when nothing failed" $
exitCodeOf [("A.hs", Unchanged), ("B.hs", decline), ("C.hs", Changed "a" "b")]
`shouldBe` Nothing
it "gives the code of the failure when there is one" $
exitCodeOf [("A.hs", failure)]
`shouldBe` Just (formatErrorExitCode (Unreadable "A.hs" "gone"))
it "gives the lowest code when there are several" $
exitCodeOf [("A.hs", failure), ("B.hs", otherFailure)]
`shouldBe` Just (min (formatErrorExitCode unreadable) (formatErrorExitCode unclaimed))
it "does not depend on the order the failures came back in" $
exitCodeOf [("A.hs", failure), ("B.hs", otherFailure)]
`shouldBe` exitCodeOf [("B.hs", otherFailure), ("A.hs", failure)]
it "is not swayed by a refusal, however many there are" $
exitCodeOf [("A.hs", decline), ("B.hs", decline)] `shouldBe` Nothing
it "gives the code of a refusal once refusing is not allowed" $
exitCodeOf [("A.hs", failIfDeclined decline), ("B.hs", failIfDeclined Unchanged)]
`shouldBe` Just (formatErrorExitCode (PositionPragmas "A.hs"))
describe "the summary an inplace run prints" $ do
it "counts the files it formatted, indented, by extension" $
reportOut (inplaceReport Plain [("A.hs", Unchanged), ("B.hs", Changed "a" "b")])
`shouldBe` " [✓] Formatted 2 .hs files\n"
it "counts each extension on its own line, in a settled order" $
reportOut
( inplaceReport
Plain
[("C.hsig", Unchanged), ("A.hs", Unchanged), ("B.hs-boot", Unchanged)]
)
`shouldBe` T.unlines
[ " [✓] Formatted 1 .hs file",
" [✓] Formatted 1 .hs-boot file",
" [✓] Formatted 1 .hsig file"
]
it "says file rather than files when there is one" $
reportOut (inplaceReport Plain [("A.hs", Unchanged)])
`shouldBe` " [✓] Formatted 1 .hs file\n"
it "counts neither a refusal nor a failure among the formatted" $
reportOut (inplaceReport Plain [("A.hs", Unchanged), ("B.hs", decline), ("C.hs", failure)])
`shouldBe` " [✓] Formatted 1 .hs file\n"
it "says nothing at all about a run with no files" $
inplaceReport Plain [] `shouldBe` Report "" []
describe "the files a run did not format" $ do
let mixed =
[ ("b.hs", decline),
("a.hs", Failed (Unreadable "a.hs" "no such file")),
("c.hs-boot", decline)
]
it "tallies refusals and failures apart, and puts refusals first" $
filter (T.isInfixOf "] ") (reportErr (inplaceReport Plain mixed))
`shouldBe` [ " [=] Declined 1 .hs file",
" [=] Declined 1 .hs-boot file",
" [✗] Failed 1 .hs file"
]
it "gives a reason for every one of them" $
length (filter opensAReason (reportErr (inplaceReport Plain mixed)))
`shouldBe` 3
it "says what a file that would not settle did" $
reportErr
(inplaceReport Plain [("A.hs", Failed (NotIdempotent "A.hs" "line 12 differs"))])
`shouldSatisfy` any (T.isInfixOf "A.hs is not idempotent: line 12 differs")
it "names the file in the reason, once" $
reportErr (inplaceReport Plain mixed)
`shouldSatisfy` any (T.isInfixOf "cannot read a.hs: no such file")
it "orders the reasons by file, not by arrival" $
reportErr (inplaceReport Plain mixed)
`shouldBe` reportErr (inplaceReport Plain (reverse mixed))
it "opens every case with a bullet" $
filter opensAReason (reportErr (inplaceReport Plain mixed))
`shouldSatisfy` all (T.isPrefixOf " · ")
it "lines a wrapped case up with the text of its bullet" $
reportErr (inplaceReport Plain [("A.hs", Failed (Unreadable "A.hs" (T.replicate 20 "and more words ")))])
`shouldSatisfy` \case
(_tallyLine : opening : continuation : _) ->
T.isPrefixOf " · " opening
&& T.takeWhile (== ' ') continuation == " "
_ -> False
it "lays an unsettled operator at the door of the run, not of the module" $
flattened (inplaceReport Plain [("A.hs", missing "Criterion.Main")])
`shouldSatisfy` T.isInfixOf
"the fixity of <|> may be declared in Criterion.Main, which this run could not read"
it "counts the modules when the answer could be in more than one" $
flattened (inplaceReport Plain [("A.hs", missingIn ["Criterion.Main", "Test.Tasty"])])
`shouldSatisfy` T.isInfixOf
"may be declared in Criterion.Main or Test.Tasty, neither of which this run could read"
it "names the module reading stopped at, not only the import above it" $
flattened
(inplaceReport Plain [("A.hs", missingThrough [ModuleChain ("Test.Hspec" :| ["Test.QuickCheck.Property"])])])
`shouldSatisfy` T.isInfixOf
"may be declared in Test.Hspec → Test.QuickCheck.Property, which this run could not read"
it "gives one answer once however many operators share it" $
flattened (inplaceReport Plain [("A.hs", missingTwice "Criterion.Main")])
`shouldSatisfy` T.isInfixOf
"the fixities of <|> and <+> may be declared in Criterion.Main, which this run could not read"
it "puts a comma before the and once there are three of them" $
flattened (inplaceReport Plain [("A.hs", missingAll ["<|>", "<+>", "<*>"] "Criterion.Main")])
`shouldSatisfy` T.isInfixOf "the fixities of <|>, <+>, and <*> may be declared in"
it "leaves a pair without one" $
flattened (inplaceReport Plain [("A.hs", missingAll ["<|>", "<+>"] "Criterion.Main")])
`shouldSatisfy` T.isInfixOf "the fixities of <|> and <+> may be declared in"
it "counts each answer's operators rather than the file's" $
flattened (inplaceReport Plain [("A.hs", twoAnswers)])
`shouldSatisfy` T.isInfixOf
"the fixity of <|> may be declared in Criterion.Main, which this run \
\could not read, and the fixity of <+> is infixl 6 in Left but infixr 5 \
\in Right"
it "names the imports that disagree and what each brings" $
flattened (inplaceReport Plain [("A.hs", about "<+>")])
`shouldSatisfy` T.isInfixOf "the fixity of <+> is infixl 6 in Left but infixr 5 in Right"
it "gathers the imports that agree with one another" $
flattened (inplaceReport Plain [("A.hs", disagreeing (leftSix :| [rightFive, ("Middle", Fixity LeftAssoc 6)]))])
`shouldSatisfy` T.isInfixOf "is infixl 6 in Left and Middle but infixr 5 in Right"
it "lists three fixities the way it lists three of anything" $
flattened (inplaceReport Plain [("A.hs", disagreeing (leftSix :| [rightFive, ("Third", Fixity NoAssoc 4)]))])
`shouldSatisfy` T.isInfixOf "is infixl 6 in Left, infixr 5 in Right, and infix 4 in Third"
it "gives one disagreement once however many operators share it" $
flattened
( inplaceReport
Plain
[ ( "A.hs",
Declined
( UnknownFixity
"A.hs"
[ ((Nothing, OpName "<|>"), Ambiguous (leftSix :| [rightFive])),
((Nothing, OpName "<+>"), Ambiguous (leftSix :| [rightFive]))
]
)
)
]
)
`shouldSatisfy` T.isInfixOf "the fixities of <|> and <+> are infixl 6 in Left but infixr 5 in Right"
it "keeps all of it off the stream the summary goes to" $
reportOut (inplaceReport Plain mixed) `shouldBe` ""
it "says the same about them in either command" $
reportErr (checkReport Plain mixed) `shouldBe` reportErr (inplaceReport Plain mixed)
describe "what a check run prints" $ do
it "shows a diff for a file that would change" $
reportOut (checkReport Plain [("A.hs", Changed "one\n" "two\n")])
`shouldSatisfy` T.isInfixOf "--- a/A.hs"
it "heads it the way git heads one" $
reportOut (checkReport Plain [("A.hs", Changed "one\n" "two\n")])
`shouldSatisfy` T.isInfixOf "diff --git a/A.hs b/A.hs"
it "shows the removal and the addition" $ do
let shown = reportOut (checkReport Plain [("A.hs", Changed "one\n" "two\n")])
shown `shouldSatisfy` T.isInfixOf "-one"
shown `shouldSatisfy` T.isInfixOf "+two"
it "says nothing on standard output about a file it did not format" $
reportOut (checkReport Plain [("A.hs", Unchanged), ("B.hs", decline), ("C.hs", failure)])
`shouldBe` ""
describe "what a for-editor run prints" $ do
it "prints the formatted module exactly, and nothing else" $
stdinReport Plain "a\n" [("A.hs", Changed "a\n" "b\n")]
`shouldBe` Report "b\n" []
it "gives the module back as it was read where nothing changed" $
stdinReport Plain "a\n" [("A.hs", Unchanged)]
`shouldBe` Report "a\n" []
it "gives the module back as it was read where it was declined, and says why" $ do
let report = stdinReport Plain "a\n" [("A.hs", decline)]
reportOut report `shouldBe` "a\n"
T.unwords (fmap T.strip (reportErr report))
`shouldSatisfy` T.isInfixOf "will not format A.hs"
it "prints no module where it failed, and says why" $ do
let report = stdinReport Plain "a\n" [("A.hs", failure)]
reportOut report `shouldBe` ""
reportErr report `shouldSatisfy` (not . null)
describe "color" $ do
it "leaves everything bare when there is nobody to see it" $
reportOut (inplaceReport Plain [("A.hs", Unchanged)])
`shouldSatisfy` (not . T.isInfixOf "\ESC")
it "colors the tick and not the brackets around it" $
reportOut (inplaceReport Colors [("A.hs", Unchanged)])
`shouldSatisfy` T.isInfixOf "[\ESC[32m✓\ESC[0m]"
it "colors the equals sign yellow" $
reportErr (inplaceReport Colors [("A.hs", decline)])
`shouldSatisfy` any (T.isInfixOf "[\ESC[33m=\ESC[0m]")
it "sets the extension in the summary in bold" $
reportOut (inplaceReport Colors [("A.hs", Unchanged)])
`shouldSatisfy` T.isInfixOf "\ESC[1m.hs\ESC[0m"
it "sets the file a case is about in bold, and nothing around it" $
reportErr (inplaceReport Colors [("src/A.hs", failureAbout "src/A.hs")])
`shouldSatisfy` any (T.isInfixOf "\ESC[1msrc/A.hs\ESC[0m")
it "leaves the file bare when there is nobody to see it" $
reportErr (inplaceReport Plain [("src/A.hs", failureAbout "src/A.hs")])
`shouldSatisfy` all (not . T.isInfixOf "\ESC")
it "sets an operator a message names in cyan" $
reportErr (inplaceReport Colors [("A.hs", about "<|>")])
`shouldSatisfy` any (T.isInfixOf "\ESC[36m<|>\ESC[0m")
it "matches an operator as a whole word and not as a fragment" $ do
let shown = T.unlines (reportErr (inplaceReport Colors [("A.hs", about ".")]))
T.count "\ESC[36m" shown `shouldBe` 1
shown `shouldSatisfy` T.isInfixOf "\ESC[1mA.hs\ESC[0m"
it "leaves the operator bare when there is nobody to see it" $
reportErr (inplaceReport Plain [("A.hs", about "<|>")])
`shouldSatisfy` all (not . T.isInfixOf "\ESC")
it "sets a module a message names in bold" $
reportErr (inplaceReport Colors [("A.hs", missing "Criterion.Main")])
`shouldSatisfy` any (T.isInfixOf "\ESC[1mCriterion.Main\ESC[0m")
it "colors a module even where a comma follows it" $
reportErr (inplaceReport Colors [("A.hs", missing "Criterion.Main")])
`shouldSatisfy` any (T.isInfixOf "\ESC[1mCriterion.Main\ESC[0m,")
it "colors the cross red" $
reportErr (inplaceReport Colors [("A.hs", failure)])
`shouldSatisfy` any (T.isInfixOf "[\ESC[31m✗\ESC[0m]")
describe "breaking text to fit" $ do
it "keeps every line within the room given" $
fmap T.length (wrapTo 20 (T.replicate 40 "word ")) `shouldSatisfy` all (<= 20)
it "breaks at spaces and nowhere else" $
wrapTo 12 "one two three four" `shouldBe` ["one two", "three four"]
it "gives a word too long for the room a line of its own" $
wrapTo 8 "a supercalifragilistic b"
`shouldBe` ["a", "supercalifragilistic", "b"]
it "keeps a line break that was already there" $
wrapTo 40 "first thing\nsecond thing"
`shouldBe` ["first thing", "second thing"]
it "has nothing to say about nothing" $
wrapTo 20 "" `shouldBe` []
it "measures what a reader will see, not what the text holds" $ do
visibleLength "abc" `shouldBe` 3
visibleLength "\ESC[31mabc\ESC[0m" `shouldBe` 3
visibleLength "\ESC[1m\ESC[36mab\ESC[0mc" `shouldBe` 3
it "breaks a colored line exactly where it breaks the bare one" $ do
let bare = "alpha beta gamma delta epsilon zeta eta theta"
lit = T.replace "gamma" "\ESC[36mgamma\ESC[0m" bare
fmap visibleLength (wrapTo 20 lit) `shouldBe` fmap T.length (wrapTo 20 bare)
describe "how wide anything gets" $ do
let sprawling =
[ ("some/quite/deeply/nested/directory/Module.hs", decline),
("another/quite/deeply/nested/one/Module.hs", failure)
]
it "never prints a line wider than it allows itself" $
fmap T.length (reportErr (inplaceReport Plain sprawling))
`shouldSatisfy` all (<= lineWidth)
it "does the same when the reason is enormous" $
fmap T.length (reportErr (inplaceReport Plain [("A.hs", Failed (Unreadable "A.hs" (T.replicate 40 "and more words ")))]))
`shouldSatisfy` all (<= lineWidth)
it "wraps what it says when it gives up, too" $
fmap T.length (noted Plain ("✗", Bad) (T.replicate 40 "and more words "))
`shouldSatisfy` all (<= lineWidth)
describe "reading a file" $ do
it "reads it as UTF-8" $
withBytes (T.encodeUtf8 "-- λ über ✓\n") $ \path ->
readAsUtf8 path `shouldReturn` Right "-- λ über ✓\n"
it "reads it as UTF-8 whatever the machine's locale is" $
withBytes (BS.pack [0xC3, 0xA9, 0x0A]) $ \path ->
readAsUtf8 path `shouldReturn` Right "é\n"
it "says so when it is not UTF-8 at all" $
withBytes (BS.pack [0x6D, 0xFF, 0xFE, 0x0A]) $ \path ->
readAsUtf8 path `shouldReturn` Left "it is not valid UTF-8"
it "says so when it is not there" $
withSystemTempDirectory "tilia-run" $ \directory ->
(isLeft <$> readAsUtf8 (directory </> "gone.hs"))
`shouldReturn` True
it "hands over the line endings it found, rather than the platform's" $
withBytes "module A where\r\n" $ \path ->
readAsUtf8 path `shouldReturn` Right "module A where\r\n"
describe "what would be written back" $ do
it "is unchanged when the formatter gave back what was there" $
differs (formattingOutcome "module A where\n" "module A where\n")
`shouldBe` False
it "is unchanged when a file with Windows endings formats to itself" $
differs (formattingOutcome "module A where\r\n" "module A where\n")
`shouldBe` False
it "puts the file's own endings back on what it writes" $
written (formattingOutcome "module A where\r\n" "module B where\n")
`shouldBe` Just "module B where\r\n"
it "leaves a file that ends its lines with newlines alone" $
written (formattingOutcome "module A where\n" "module B where\n")
`shouldBe` Just "module B where\n"
it "takes the endings from the first line, over a file of many" $
written (formattingOutcome "a\r\nb\r\nc\r\n" "a\nb\nd\n")
`shouldBe` Just "a\r\nb\r\nd\r\n"
it "counts a file whose endings disagree as one that would change" $
differs (formattingOutcome "a\r\nb\nc\r\n" "a\nb\nc\n") `shouldBe` True
it "says as much in the diff, having nothing else to show" $
reportOut (checkReport Plain [("A.hs", formattingOutcome "a\r\nb\nc\r\n" "a\nb\nc\n")])
`shouldSatisfy` T.isInfixOf "differ only in how they end their lines"
it "leaves a carriage return that is not a line ending where it is" $
written (formattingOutcome "x = \"a\\\r b\"\r\n" "y = \"a\\\r b\"\n")
`shouldBe` Just "y = \"a\\\r b\"\r\n"
describe "putting a file back" $ do
it "writes one that changed" $
withSource "module A where\n" $ \path -> do
writeBack (path, Changed "module A where\n" "module B where\n")
T.readFile path `shouldReturn` "module B where\n"
it "writes it as UTF-8, and as the bytes it was given" $
withSource "module A where\n" $ \path -> do
writeBack (path, Changed "module A where\n" "-- ✓ über\r\n")
BS.readFile path `shouldReturn` T.encodeUtf8 "-- ✓ über\r\n"
it "takes back exactly what it read, over a whole round trip" $
withBytes (T.encodeUtf8 "-- ü\r\nmodule A where\r\n") $ \path -> do
Right asItIs <- readAsUtf8 path
writeBack (path, formattingOutcome asItIs "-- ü\nmodule B where\n")
BS.readFile path `shouldReturn` T.encodeUtf8 "-- ü\r\nmodule B where\r\n"
it "leaves one that did not alone, down to its modification time" $
untouched Unchanged
it "leaves one it would not format alone" $
untouched decline
it "leaves one it could not format alone" $
untouched failure
describe "running over many files at once" $ do
it "answers in the order it was asked, not the order it finished" $ do
answers <- inParallel (\n -> threadDelay (1000 * (40 - n)) >> pure n) [1 .. 20 :: Int]
answers `shouldBe` [1 .. 20]
it "runs every one of them exactly once" $ do
seen <- newIORef (0 :: Int)
_ <- inParallel (\_ -> atomicModifyIORef' seen (\n -> (n + 1, ()))) [1 .. 500 :: Int]
readIORef seen `shouldReturn` 500
it "copes with having nothing to do" $
inParallel pure ([] :: [Int]) `shouldReturn` []
it "really does run them at once" $ do
capabilities <- getNumCapabilities
let rounds = ceiling (20 / fromIntegral capabilities :: Double) :: Int
started <- getMonotonicTime
_ <- inParallel (\_ -> threadDelay 100000) [1 .. 20 :: Int]
finished <- getMonotonicTime
(finished - started) `shouldSatisfy` (< fromIntegral rounds * 0.1 + 0.4)
----------------------------------------------------------------------------
-- Helpers
unreadable, unclaimed :: FormatError
unreadable = Unreadable "A.hs" "gone"
unclaimed = NoPackage "B.hs" NoPackageFile
-- | A file we would not touch, and two we could not.
decline, failure, otherFailure :: Outcome
decline = Declined (PositionPragmas "A.hs")
failure = Failed unreadable
otherFailure = Failed unclaimed
-- | A file with the given contents, in a directory of its own.
withSource :: Text -> (FilePath -> IO a) -> IO a
withSource contents act =
withSystemTempDirectory "tilia-run" $ \directory -> do
let path = directory </> "A.hs"
T.writeFile path contents
act path
-- | The same, for a file that has to hold exactly these bytes.
withBytes :: BS.ByteString -> (FilePath -> IO a) -> IO a
withBytes contents act =
withSystemTempDirectory "tilia-run" $ \directory -> do
let path = directory </> "A.hs"
BS.writeFile path contents
act path
-- | What writing this outcome back would put in the file.
written :: Outcome -> Maybe Text
written = \case
Changed _ wouldBe -> Just wouldBe
_ -> Nothing
-- | Writing this outcome back should do nothing whatsoever.
untouched :: Outcome -> Expectation
untouched outcome =
withSource "module A where\n" $ \path -> do
stamped <- getModificationTime path
threadDelay 10000
writeBack (path, outcome)
stampedAgain <- getModificationTime path
stampedAgain `shouldBe` stamped
T.readFile path `shouldReturn` "module A where\n"
-- | Is this the first line of an explanation rather than a continuation?
opensAReason :: Text -> Bool
opensAReason line = T.isPrefixOf " " line && not (T.isPrefixOf " " line)
-- | A failure that names the file it is about, as the real ones do.
failureAbout :: FilePath -> Outcome
failureAbout path = Failed (Unreadable path "no such file")
-- | A case about one operator whose fixity could not be settled.
about :: Text -> Outcome
about op = Declined (UnknownFixity "A.hs" [((Nothing, OpName op), Ambiguous (leftSix :| [rightFive]))])
-- | A case about @<+>@, which the imports bring in as given.
disagreeing :: NonEmpty (Text, Fixity) -> Outcome
disagreeing offers = Declined (UnknownFixity "A.hs" [((Nothing, OpName "<+>"), Ambiguous offers)])
-- | What two disagreeing imports bring.
leftSix, rightFive :: (Text, Fixity)
leftSix = ("Left", Fixity LeftAssoc 6)
rightFive = ("Right", Fixity RightAssoc 5)
-- | A case about an operator whose fixity is in a module we could not read.
missing :: Text -> Outcome
missing modName = missingIn [modName]
-- | The same, where more than one module could have declared it.
missingIn :: [Text] -> Outcome
missingIn = missingThrough . fmap (ModuleChain . (:| []))
-- | Two operators with the one answer between them, which is said once.
missingTwice :: Text -> Outcome
missingTwice = missingAll ["<|>", "<+>"]
-- | Two operators with an answer each, so neither is a plurality.
twoAnswers :: Outcome
twoAnswers =
Declined
( UnknownFixity
"A.hs"
[ ((Nothing, OpName "<|>"), NotRead (ModuleChain ("Criterion.Main" :| []) :| [])),
((Nothing, OpName "<+>"), Ambiguous (leftSix :| [rightFive]))
]
)
-- | Any number of them, all with the one answer between them.
missingAll :: [Text] -> Text -> Outcome
missingAll ops modName =
Declined
( UnknownFixity
"A.hs"
[((Nothing, OpName op), NotRead names) | op <- ops]
)
where
names = ModuleChain (modName :| []) :| []
-- | The same again, where each import is given down to the module that
-- actually stopped the reading.
missingThrough :: [ModuleChain] -> Outcome
missingThrough chains =
Declined (UnknownFixity "A.hs" [((Nothing, OpName "<|>"), NotRead names)])
where
names = case chains of
[] -> error "an operator has to be missing from somewhere"
c : cs -> c :| cs
-- | A report as one piece of text, with the breaks it was wrapped at undone.
flattened :: Report -> Text
flattened = T.unwords . fmap T.strip . reportErr