diagnostician-0.2.0.0: src/Diagnostician.hs
-- SPDX-FileCopyrightText: 2026 Coln contributors
--
-- SPDX-License-Identifier: Apache-2.0 OR MIT
module Diagnostician where
import Data.Functor.Contravariant
import Data.IORef (IORef, atomicModifyIORef)
import Data.Map (Map)
import Data.Map qualified as Map
import Data.Maybe (maybeToList)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Unsafe qualified as TU
import Data.Vector.Unboxed qualified as UV
import Prettyprinter
import Prettyprinter.Render.Text
import System.IO (Handle)
-- Pretty printer annotations
--------------------------------------------------------------------------------
data DiagnosticAnn
= DSeverity
| DCode
| DBar
| DSpan
-- Diagnostician doc
type DDoc = Doc DiagnosticAnn
-- | Pretty for diagnostics
class DPretty a where
dpretty :: a -> DDoc
-- Source locations
--------------------------------------------------------------------------------
type Pos = Int
data Span = Span {start :: Int, end :: Int}
deriving (Eq)
instance DPretty Span where
dpretty (Span s e) = pretty s <> ":" <> pretty e
-- Util
--------------------------------------------------------------------------------
sliceWord8 :: Pos -> Pos -> Text -> Text
sliceWord8 s e t = TU.dropWord8 s $ TU.takeWord8 e t
-- Files
--------------------------------------------------------------------------------
{- | A @File@ is used to display diagnostic messages.
Specifically, a @File@ is used to convert the @Span@ in a diagnostic message
into snippet of source code with the span underlined.
In order to do this, we need to convert the byte positions in the @Span@ into
line/column positions. This can be done fairly efficiently by binary search
through the vector of newline positions in the file, so we create this vector
whenever we open a file and store it in the @File@ record.
-}
data File = File
{ name :: FilePath
, contents :: T.Text
, lineBreaks :: UV.Vector Pos
}
newFile :: FilePath -> T.Text -> File
newFile x t = File x t (findLineBreaks t)
findLineBreaks :: T.Text -> UV.Vector Pos
findLineBreaks t = UV.unfoldr nextNewline (-1)
where
l = TU.lengthWord8 t
nextNewline i
| i == -1 = Just (-1, 0)
| i < l = case TU.iter t i of
TU.Iter '\n' j -> Just (i, i + j)
TU.Iter _ j -> nextNewline (i + j)
| i == l = Just (i, i + 1)
| otherwise = Nothing
type LineNum = Int
type ColNum = Int
lineStart :: File -> LineNum -> Pos
lineStart f l = (f.lineBreaks UV.! l) + 1
lineEnd :: File -> LineNum -> Pos
lineEnd f l = f.lineBreaks UV.! (l + 1)
lineSpan :: File -> LineNum -> Span
lineSpan f l = Span (lineStart f l) (lineEnd f l)
lineContents :: File -> LineNum -> T.Text
lineContents f l = sliceWord8 (lineStart f l) (lineEnd f l) f.contents
srcOf :: File -> Pos -> (LineNum, ColNum)
srcOf f i =
seq
(0 <= i && i <= TU.lengthWord8 f.contents || error "position out of bounds")
(go 0 (UV.length f.lineBreaks - 1))
where
go l r
| l == r = (l, c l)
| i < lineStart f m = go l m
| i > lineEnd f m = go m r
| otherwise = (m, c m)
where
m = (l + r) `div` 2
c lineNo = i - lineStart f lineNo
repeated :: Int -> Char -> Doc ann
repeated n c
| n == 0 = mempty
| n == 1 = pretty c
| otherwise = pretty (T.replicate n (T.singleton c))
linePretty :: Int -> LineNum -> Span -> T.Text -> Span -> DDoc
linePretty numWidth l (Span ls le) t (Span s e) =
vsep
[ gutter <+> pretty t
, gutter <+> repeated ns ' ' <> annotate DSpan (repeated nc '^')
]
where
s' = max ls s
e' = min le e
ns = T.length $ sliceWord8 0 (s' - ls) t
nc = max 1 $ T.length $ sliceWord8 (s' - ls) (e' - ls) t
ln = fill numWidth $ pretty $ l + 1
gutter = ln <+> annotate DBar "|"
numDigits :: Int -> Int
numDigits n = go (abs n)
where
go x
| x < 10 = 1
| otherwise = 1 + go (x `div` 10)
-- | This is the function used to display the source code for a @Span@.
linesPretty :: File -> Span -> DDoc
linesPretty f sp@(Span s e) =
vsep
[ linePretty numWidth l (lineSpan f l) (lineContents f l) sp
| l <- [ls .. le]
]
where
ls = fst $ srcOf f s
le = fst $ srcOf f e
numWidth = max (numDigits ls) (numDigits le)
-- Reporter
--------------------------------------------------------------------------------
{- | A @Reporter@ is a destination for diagnostic messages. It corresponds
to an IO action `reportIO` that logs a diagnostic
-}
newtype Reporter a = Reporter {reportIO :: Diagnostic a -> IO ()}
reportTo :: Reporter a -> Diagnostic a -> IO ()
reportTo r d = r.reportIO d
instance Contravariant Reporter where
contramap f (Reporter r) = Reporter $ r . fmap f
-- | Create a `Reporter` that writes the prettyprinted diagnostic to a file
fileReporter :: (Code a) => Handle -> Reporter a
fileReporter handle =
Reporter
{ reportIO = \d -> hPutDoc handle (hardline <> dpretty d <> hardline)
}
-- | Create a `Reporter` that appends a diagnostic to a list
pureReporter :: (Code a) => IORef [Diagnostic a] -> Reporter a
pureReporter ref =
Reporter
{ reportIO = \d -> atomicModifyIORef ref (\ds -> (d : ds, ()))
}
-- Diagnostics
--------------------------------------------------------------------------------
data Severity = SDebug | SInfo | SWarning | SError
data CodeMeta = CodeMeta
{ number :: Int
, severity :: Severity
, about :: Maybe Text
}
class Code a where
codeMeta :: a -> CodeMeta
promoteCodeTable :: (Ord b) => Map a CodeMeta -> (a -> b) -> Int -> Map b CodeMeta
promoteCodeTable t f offset =
Map.fromList
[(f c, m{number = m.number + offset}) | (c, m) <- Map.toList t]
padWithZerosTo :: Int -> Int -> Doc ann
padWithZerosTo w i = repeated (w - numDigits i) '0' <> pretty i
prtCode :: (Code a) => a -> DDoc
prtCode c = annotate DSeverity s <> "[" <> annotate DCode (sl <> padWithZerosTo 4 m.number) <> "]"
where
m = codeMeta c
(s, sl) = case m.severity of
SDebug -> ("debug", "D")
SInfo -> ("info", "I")
SWarning -> ("warning", "W")
SError -> ("error", "E")
data SourceLoc = SourceLoc
{ file :: File
, span :: Span
}
instance DPretty SourceLoc where
dpretty (SourceLoc f s) = linesPretty f s
data Note = Note
{ noteSourceLoc :: Maybe SourceLoc
, noteMessage :: Maybe DDoc
}
instance DPretty Note where
dpretty (Note loc message) =
vsep $
(dpretty <$> maybeToList loc) ++ (unAnnotate <$> maybeToList message)
data Diagnostic a = Diagnostic
{ code :: a
, summary :: DDoc
, notes :: [Note]
}
deriving (Functor)
instance (Code a) => DPretty (Diagnostic a) where
dpretty d =
vsep $
(prtCode d.code <> ": " <> unAnnotate d.summary) : (map dpretty d.notes)