packages feed

fast-tags-0.0.1: src/Main.hs

{-# LANGUAGE OverloadedStrings, PatternGuards #-}
{- |
    Annotate lines, strip comments, tokenize, then search for
    It loads the existing tags, and updates it for the given file.  Then
    a SaveBuf action runs tags every time you save a file.
    Or extend lushtags, the question is complete parsing ok?  It means I can't
    do it on save, since I'd have to invoke the build system to de-hsc.
-}
module Main where
import qualified Control.Exception as Exception
import Control.Monad
import qualified Data.Char as Char
import qualified Data.Either as Either
import qualified Data.List as List
import qualified Data.Monoid as Monoid
import qualified Data.Text as T
import Data.Text (Text)
import qualified Data.Text.IO as Text.IO

import qualified System.Console.GetOpt as GetOpt
import qualified System.Environment as Environment
import qualified System.Exit
import qualified System.IO as IO
import qualified System.IO.Error as IO.Error


main :: IO ()
main = do
    args <- Environment.getArgs
    (flags, inputs) <- case GetOpt.getOpt GetOpt.Permute options args of
        (flags, inputs, []) -> return (flags, inputs)
        (_, _, errs) -> usage $ "flag errors:\n" ++ List.intercalate ", " errs
    let output = last $ "tags" : [fn | Output fn <- flags]
    oldTags <- fmap (maybe [vimMagicLine] T.lines) $
        catchENOENT $ Text.IO.readFile output
    tags <- fmap concat (mapM processFile inputs)

    let (warnings, newTags) = Either.partitionEithers $ map showTag tags
    forM_ warnings $ \warn -> do
        IO.hPutStrLn IO.stderr warn
    let write = if output == "-" then Text.IO.hPutStr IO.stdout
            else Text.IO.writeFile output
    write $ T.unlines $
        merge (List.sort newTags) (filter (not . isNewTag inputs) oldTags)
    where
    usage msg = putStr (GetOpt.usageInfo msg options)
        >> System.Exit.exitSuccess

data Flag = Output FilePath
    deriving (Show)

options :: [GetOpt.OptDescr Flag]
options =
    [ GetOpt.Option ['o'] [] (GetOpt.ReqArg Output "blorch")
        "output file, defaults to 'tags'"
    ]

-- | Documented in vim :h tags-file-format.
-- This tells vim that the file is sorted (but not case folded) so that
-- it can do a bsearch and never needs to fall back to linear search.
vimMagicLine :: Text
vimMagicLine = "!_TAG_FILE_SORTED\t1\t~"

isNewTag :: [FilePath] -> Text -> Bool
isNewTag fns line = any (`T.isInfixOf` line) textFns
    where textFns = map (T.pack . ('\t':)) fns

merge :: [Text] -> [Text] -> [Text]
merge [] ys = ys
merge xs [] = xs
merge (x:xs) (y:ys)
    | x <= y = x : merge xs (y:ys)
    | otherwise = y : merge (x:xs) ys


-- AbsoluteMark\tCmd/TimeStep.hs 67 ;" f
-- TODO use "file:" for non-exported symbols, this should make vim search for
-- them first within the file.
showTag :: Tag -> Either String Text
showTag (Pos (SrcPos fn lineno) (Tag text typ)) = Right $
    T.concat [text, "\t", T.pack fn, "\t", T.pack (show lineno), " ;\" ",
        T.singleton (showType typ)]
showTag (Pos pos (Warning text)) = Left $ show pos ++ ": " ++ text

showType :: Type -> Char
showType typ = case typ of
    Module -> 'm'
    Function -> 'f'
    Class -> 'c'
    Type -> 't'
    Constructor -> 'C'

-- * types

data TagVal = Tag !Text !Type | Warning !String
    deriving (Eq, Show)

data Type = Module | Function | Class | Type | Constructor
    deriving (Eq, Show)

data TokenVal = Token !Text | Newline !Int
    deriving (Eq, Show)

type Tag = Pos TagVal
type Token = Pos TokenVal
type Line = Pos Text

data Pos a = Pos {
    posOf :: SrcPos
    , valOf :: a
    }

data SrcPos = SrcPos {
    posFile :: !FilePath
    , posLine :: !Int
    }

instance (Show a) => Show (Pos a) where
    show (Pos pos val) = show pos ++ ":" ++ show val
instance Show SrcPos where
    show (SrcPos fn line) = fn ++ ":" ++ show line

processFile :: FilePath -> IO [Tag]
processFile fn = fmap (process fn) (Text.IO.readFile fn)

process :: FilePath -> Text -> [Tag]
process fn = dropDups tagText . concatMap blockTags . breakBlocks
    . stripComments . concatMap tokenize . stripCpp . annotate fn
    where
    tagText (Pos _ (Tag text _)) = text
    tagText (Pos _ (Warning warn)) = T.pack warn


-- * tokenize

annotate :: FilePath -> Text -> [Line]
annotate fn text =
    [Pos (SrcPos fn num) line | (num, line) <- zip [1..] (T.lines text)]

-- | Also strips out hsc detritus.
stripCpp :: [Line] -> [Line]
stripCpp = filter $ not . ("#" `T.isPrefixOf`) . valOf

tokenize :: Line -> [Token]
tokenize (Pos pos line) = map (Pos pos) (tokenizeLine line)

tokenizeLine :: Text -> [TokenVal]
tokenizeLine text = Newline nspaces : go line
    where
    nspaces = T.count " " spaces + T.count "\t" spaces * 8
    (spaces, line) = T.break (not . Char.isSpace) text
    symbols = ["--", "{-", "-}", "=>", "->", "::"]
    go unstripped
        | T.null text = []
        | Just sym <- List.find (`T.isPrefixOf` text) symbols =
            Token sym : go (T.drop (T.length sym) text)
        | c == '\'' = let (token, rest) = breakChar text
            in Token (T.cons c token) : go rest
        | c == '"' = let (token, rest) = breakString cs
            in Token (T.cons c token) : go rest
        | Char.isSymbol c || Char.isPunctuation c =
            Token (T.singleton c) : go cs
        | otherwise =
            let (token, rest) = T.span isIdent text in Token token : go rest
        where
        text = T.dropWhile Char.isSpace unstripped
        c = T.head text
        cs = T.tail text
    isIdent c = Char.isAlphaNum c || c == '.' || c == '\'' || c == '_'

breakChar :: Text -> (Text, Text)
breakChar text
    | T.null text = ("", "")
    | T.head text == '\\' = T.splitAt 3 text
    | otherwise = T.splitAt 2 text

-- | Break after the ending double-quote of a string.
--
-- TODO \ continuation isn't supported.  I'd have to tokenize at the file
-- level instead of the line level.
breakString :: Text -> (Text, Text)
breakString text = case T.uncons post of
        Nothing -> (text, "")
        Just (c, cs)
            | c == '\\' && T.null cs -> (T.snoc pre c, "")
            | c == '\\' && T.head cs == '"' ->
                let (pre', post') = breakString (T.tail cs)
                in (pre <> "\\\"" <> pre', post')
            | c == '\\' ->
                let (pre', post') = breakString cs
                in (T.snoc pre c <> pre', post')
            | otherwise -> (T.snoc pre c, cs)
    where
    (pre, post) = T.break (\c -> c == '\\' || c == '"') text

stripComments :: [Token] -> [Token]
stripComments = go 0
    where
    go :: Int -> [Token] -> [Token]
    go _ [] = []
    go nest (pos@(Pos _ token) : rest)
        | token == Token "--" = go nest (dropLine rest)
        | token == Token "{-" = go (nest+1) rest
        | token == Token "-}" = go (nest-1) rest
        | nest > 0 = go nest rest
        | otherwise = pos : go nest rest


-- | Break the input up into blocks based on indentation and strip out all
-- the newlines.  This way the blockTags doesn't need to worry about newlines.
breakBlocks :: [Token] -> [[Token]]
breakBlocks =
    filter (not . null) . map (filter (not . isNewline)) . go . filterBlank
    where
    go [] = []
    go tokens = pre : breakBlocks post
        -- Start the indent off at 1, this way the next line in the 0th column
        -- will start a new block.
        where (pre, post) = breakBlock True 1 tokens
    -- Blank lines mess up the indentation.
    filterBlank [] = []
    filterBlank (Pos _ (Newline _) : xs@(Pos _ (Newline _) : _)) =
        filterBlank xs
    filterBlank (x:xs) = x : filterBlank xs

-- | Take until newline, then take lines until there the indent decreases.
breakBlock :: Bool -> Int -> [Token] -> ([Token], [Token])
breakBlock _ _ [] = ([], [])
breakBlock firstLine indent (t@(Pos _ (Newline lineIndent)) : ts)
    -- An indent less than the established block indent means I'm done.
    | lineIndent < indent = ([t], ts)
    | otherwise =
        let (pre, post) = breakBlock False blockIndent ts in (t:pre, post)
    where blockIndent = if firstLine then lineIndent else indent
breakBlock firstLine indent (t : ts) =
    let (pre, post) = breakBlock firstLine indent ts in (t:pre, post)


-- * extract tags

-- | Get all the tags in one indented block.
--
-- Search for:
-- - module <word>
-- - newtype <word>
-- - type <word>
-- - data <word> * = <word> :: or | <word>
-- - class * => <word> * where - strip indent, look for <word> ::
-- - <word> :: emit as a definition
-- - whitespace: skip to next line
blockTags :: [Token] -> [Tag]
blockTags tokens = case tokens of
    [] -> []
    Pos _ (Token "module") : Pos pos (Token name) : _ ->
        [mktag pos (snd (T.breakOnEnd "." name)) Module]
    -- newtype X * = X *
    Pos _ (Token "newtype") : Pos pos (Token name) : rest ->
        mktag pos name Type : newtypeTags pos rest
    -- type X * = ...
    Pos _ (Token "type") : Pos pos (Token name) : _ -> [mktag pos name Type]
    -- data X * = X { X :: *, X :: * }
    -- data X * where ...
    Pos _ (Token "data") : Pos pos (Token name) : rest ->
        mktag pos name Type : dataTags pos rest
    -- class * => X where X :: * ...
    Pos pos (Token "class") : rest -> classTags pos rest
    Pos pos (Token name) : Pos _ (Token "::") : _ -> [mktag pos name Function]
    _ -> []

mktag :: SrcPos -> Text -> Type -> Pos TagVal
mktag pos name typ = Pos pos (Tag name typ)

warning :: SrcPos -> String -> Pos TagVal
warning pos warn = Pos pos (Warning warn)

unexpected :: SrcPos -> [Token] -> [Token] -> String -> [Tag]
unexpected prevPos tokens tokensHere declaration =
    [warning pos ("unexpected " ++ thing ++ " after " ++ declaration)]
    where
    thing = if null tokensHere then "end of block"
        else show (valOf (head tokensHere))
    pos
        | not (null tokensHere) = posOf (head tokensHere)
        | not (null tokens) = posOf (last tokens)
        | otherwise = prevPos

-- | * = X *
newtypeTags :: SrcPos -> [Token] -> [Tag]
newtypeTags prevPos tokens = case dropUntil "=" tokens of
    Pos pos (Token name) : _ -> [mktag pos name Constructor]
    rest -> unexpected prevPos tokens rest "newtype * ="

-- | [] (empty data declaration)
-- * = X { X :: *, X :: * }
-- * where X :: * X :: *
-- * = X | X
dataTags :: SrcPos -> [Token] -> [Tag]
dataTags _ [] = [] -- empty data declaration
dataTags prevPos tokens
    -- if is gadt then...
    | otherwise = case dropUntil "=" tokens of
        Pos pos (Token name) : rest ->
            mktag pos name Constructor : collectRest rest
        rest -> unexpected prevPos tokens rest "data * ="
    where
    collectRest tokens = case tokens of
        Pos pos (Token name) : Pos _ (Token "::") : rest ->
            mktag pos name Function : collectRest rest
        Pos _ (Token "|") : Pos pos (Token name) : rest ->
            mktag pos name Constructor : collectRest rest
        _ : rest -> collectRest rest
        [] -> []

-- | * => X where X :: * ...
classTags :: SrcPos -> [Token] -> [Tag]
classTags prevPos unstripped = case tokens of
    Pos pos (Token name) : rest ->
        mktag pos name Class : collectRest rest
    rest -> unexpected prevPos tokens rest "class * =>"
    where
    tokens = if any ((== Token "=>") . valOf) unstripped
        then dropUntil "=>" unstripped else unstripped
    collectRest tokens = case tokens of
        Pos pos (Token name) : Pos _ (Token "::") : rest ->
            mktag pos name Function : collectRest rest
        _ : rest -> collectRest rest
        [] -> []

dropLine :: [Token] -> [Token]
dropLine = drop 1 . dropWhile (not . isNewline)

isNewline :: Token -> Bool
isNewline (Pos _ (Newline _)) = True
isNewline _ = False

dropUntil :: Text -> [Token] -> [Token]
dropUntil token = drop 1 . dropWhile ((/= Token token) . valOf)


-- * misc

(<>) :: (Monoid.Monoid a) => a -> a -> a
(<>) = Monoid.mappend

-- | If @op@ raised ENOENT, return Nothing.
catchENOENT :: IO a -> IO (Maybe a)
catchENOENT op = Exception.handleJust (guard . IO.Error.isDoesNotExistError)
    (const (return Nothing)) (fmap Just op)

dropDups :: (Eq k) => (a -> k) -> [a] -> [a]
dropDups _ [] = []
dropDups key (x:xs) = x : map snd (filter (not . equal) (zip (x:xs) xs))
    where equal (x, y) = key x == key y