tilia-0.0.1.0: src/Tilia/Source/Lines.hs
{-# LANGUAGE OverloadedStrings #-}
-- | The lines of a module, and the questions that can be asked of them
-- without a parse.
--
-- Apart from "Tilia.Source" because a 'Tilia.Source.Source' cannot be had
-- until the module has been parsed, and two things need these answers
-- earlier than that: the preprocessor support, which asks what the author
-- wrote on a line while it is still deciding what to hand the parser, and
-- the comment machinery, which "Tilia.Source" itself is built on top of.
module Tilia.Source.Lines
( -- * The lines
Written (..),
Lines,
linesOf,
dropping,
lineTexts,
lineAt,
blankAt,
directiveAt,
blankBelow,
closesABranch,
)
where
import Data.Char (isAsciiLower, isSpace)
import Data.IntMap.Strict (IntMap)
import Data.IntMap.Strict qualified as IntMap
import Data.IntSet (IntSet)
import Data.IntSet qualified as IntSet
import Data.Text (Text)
import Data.Text qualified as T
-- | The text of a module as its author wrote it.
--
-- Distinguished from the text handed to the parser because the two are the
-- same only when the preprocessor is not involved.
newtype Written = Written Text
deriving (Eq, Show)
-- | The lines of a source, numbered from one as the compiler numbers them,
-- but also the lines that a particular CPP configuration does not contain.
data Lines = Lines
{ -- | Every line of the module as written.
lnWritten :: !(IntMap Text),
-- | The ones this configuration does not contain.
lnDropped :: !IntSet
}
-- | Read the lines of a module, every one of which it has.
linesOf :: Written -> Lines
linesOf (Written text) =
Lines
{ lnWritten = IntMap.fromList (zip [1 ..] (T.lines text)),
lnDropped = IntSet.empty
}
-- | Drop the given ranges from the 'Lines'.
dropping :: [(Int, Int)] -> Lines -> Lines
dropping ranges ls =
ls
{ lnDropped =
IntSet.union
(lnDropped ls)
(IntSet.fromList (concat [[from .. to] | (from, to) <- ranges]))
}
-- | Every line of the module as written, in order, whatever this
-- configuration has of them.
lineTexts :: Lines -> [Text]
lineTexts = IntMap.elems . lnWritten
-- | The text of a line, if this configuration of the module has one.
lineAt :: Int -> Lines -> Maybe Text
lineAt n ls
| IntSet.member n (lnDropped ls) = Nothing
| otherwise = IntMap.lookup n (lnWritten ls)
-- | Was this line empty?
blankAt :: Int -> Lines -> Bool
blankAt n = maybe False (T.all isSpace) . lineAt n
-- | Does this line hold a preprocessor directive?
directiveAt :: Int -> Lines -> Bool
directiveAt n = maybe False opensWithHash . lineAt n
where
opensWithHash l = case T.uncons (T.stripStart l) of
Just ('#', rest) ->
maybe False (isAsciiLower . fst) (T.uncons (T.stripStart rest))
_ -> False
-- | Did the author leave an empty line below this line?
blankBelow :: Int -> Lines -> Bool
blankBelow start ls = go (start + 1)
where
go n
| n > IntMap.size (lnWritten ls) = False
| Nothing <- lineAt n ls = go (n + 1)
| leadsOut n = go (n + 1)
| otherwise = blankAt n ls
leadsOut n = case lineAt n ls of
Just l | directiveAt n ls -> keywordOf l `elem` leavingKeywords
_ -> False
keywordOf l = T.takeWhile isAsciiLower (T.stripStart (T.drop 1 (T.stripStart l)))
-- | Does the empty line under this one stand at the end of a branch?
closesABranch :: Int -> Lines -> Bool
closesABranch n ls = go False (n + 1)
where
go crossed k
| k > IntMap.size (lnWritten ls) = False
| otherwise = case lineAt k ls of
Nothing -> go crossed (k + 1)
Just l
| T.null (T.strip l) -> go True (k + 1)
| directiveAt k ls -> crossed && keywordOf l `elem` leavingKeywords
| otherwise -> False
keywordOf l = T.takeWhile isAsciiLower (T.stripStart (T.drop 1 (T.stripStart l)))
-- | The directives that lead out of the region the line below them is in,
-- rather than into one it is not.
leavingKeywords :: [Text]
leavingKeywords = ["elif", "elifdef", "elifndef", "else", "endif"]