toodles-0.1.0.13: app/Main.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveDataTypeable #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeOperators #-}
-- TODO (avi|p=3|#cleanup|key=val|k3y=asdf|key=1) - break this into modules
module Main where
import qualified Control.Exception as E
import Control.Monad
import Control.Monad (forM)
import Control.Monad.IO.Class
import Data.Aeson
import Data.Either
import Data.IORef
import Data.List
import Data.Maybe
import Data.Monoid
import Data.Proxy
import Data.String.Utils
import qualified Data.Text as T
import Data.Version (showVersion)
import Data.Void
import qualified Data.Yaml as Y
import GHC.Generics
import Network.Wai.Handler.Warp
import Paths_toodles
import Servant
import Servant.HTML.Blaze
import System.Console.CmdArgs
import System.Directory
import System.IO.HVFS
import qualified System.IO.Strict as SIO
import System.Path
import System.Path.NameManip
import qualified Text.Blaze.Html5 as BZ
import Text.Megaparsec
import Text.Megaparsec.Char
import qualified Text.Megaparsec.Char.Lexer as L
import Text.Printf
import Text.Read
import Text.Regex.Posix
type LineNumber = Integer
data TodoEntry
= TodoEntryHead { id :: Integer
, body :: [T.Text]
, assignee :: Maybe T.Text
, sourceFile :: FilePath
, lineNumber :: LineNumber
, priority :: Maybe Integer
, customAttributes :: [(T.Text, T.Text)]
, tags :: [T.Text]
, leadingText :: T.Text }
| TodoBodyLine T.Text
deriving (Show, Generic)
data TodoListResult = TodoListResult
{ todos :: [TodoEntry]
, message :: T.Text
} deriving (Show, Generic)
newtype DeleteTodoRequest = DeleteTodoRequest
{ ids :: [Integer]
} deriving (Show, Generic)
data EditTodoRequest = EditTodoRequest
{ editIds :: [Integer]
, setAssignee :: Maybe T.Text
, addTags :: [T.Text]
, addKeyVals :: [(T.Text, T.Text)]
, setPriority :: Maybe Integer
} deriving (Show, Generic)
newtype ToodlesConfig = ToodlesConfig {
ignore :: [FilePath]
} deriving (Show, Generic)
instance FromJSON TodoEntry
instance ToJSON TodoEntry
instance FromJSON TodoListResult
instance ToJSON TodoListResult
instance FromJSON DeleteTodoRequest
instance ToJSON DeleteTodoRequest
instance FromJSON EditTodoRequest
instance ToJSON EditTodoRequest
instance FromJSON ToodlesConfig
type ToodlesAPI =
"todos" :> QueryFlag "recompute" :> Get '[ JSON] TodoListResult :<|>
"todos" :> "delete" :> ReqBody '[ JSON] DeleteTodoRequest :> Post '[ JSON] T.Text :<|>
"todos" :> "edit" :> ReqBody '[ JSON] EditTodoRequest :> Post '[ JSON] T.Text :<|>
"static" :> Raw :<|>
"source_file" :> Capture "id" Integer :> Get '[ HTML] BZ.Html :<|>
CaptureAll "anything-else" T.Text :> Get '[HTML] BZ.Html
server :: ToodlesState -> Server ToodlesAPI
server s =
liftIO . getFullSearchResults s :<|>
deleteTodos s :<|>
editTodos s :<|>
serveDirectoryFileServer (dataPath s) :<|>
showRawFile s :<|>
root s
data ToodlesState = ToodlesState
{ results :: IORef TodoListResult,
dataPath :: FilePath
}
toodlesAPI :: Proxy ToodlesAPI
toodlesAPI = Proxy
slice :: Int -> Int -> [a] -> [a]
slice f t xs = take (t - f + 1) (drop f xs)
doUntilNull :: ([a] -> IO [a]) -> [a] -> IO ()
doUntilNull f xs = do
result <- f xs
if null result
then return ()
else doUntilNull f result
removeTodoFromCode :: MonadIO m => TodoEntry -> m ()
removeTodoFromCode = updateTodoLinesInFile (const [])
-- | Given a function to emit new lines for a given todo, write that update in
-- place of the current todo lines
updateTodoLinesInFile ::
MonadIO m => (TodoEntry -> [T.Text]) -> TodoEntry -> m ()
updateTodoLinesInFile f todo = do
let startIndex = lineNumber todo - 1
newLines = map T.unpack $ f todo
fileLines <- liftIO $ lines <$> SIO.readFile (sourceFile todo)
let updatedLines =
slice 0 (fromIntegral $ startIndex - 1) fileLines ++ newLines ++
(slice
(fromIntegral startIndex + length (body todo))
(length fileLines - 1)
fileLines)
liftIO $ writeFile (sourceFile todo) $ unlines updatedLines
removeAndAdjust :: MonadIO io => [TodoEntry] -> io [TodoEntry]
removeAndAdjust [] = return []
removeAndAdjust (x:xs) = do
removeTodoFromCode x
forM xs $ \t ->
if (sourceFile t == sourceFile x) &&
(lineNumber t > lineNumber x)
then return $ t { lineNumber = lineNumber t - (fromIntegral . length $ body x)
}
else return t
deleteTodos :: ToodlesState -> DeleteTodoRequest -> Handler T.Text
deleteTodos (ToodlesState ref _) req = do
refVal@(TodoListResult r _) <- liftIO $ readIORef ref
let toDelete = filter (\t -> Main.id t `elem` ids req) r
liftIO $ doUntilNull removeAndAdjust toDelete
let updeatedResults =
refVal
{ todos =
filter (\t -> Main.id t `notElem` map Main.id toDelete) r
}
_ <-
liftIO $ atomicModifyIORef' ref (const (updeatedResults, updeatedResults))
return "{}"
editTodos :: ToodlesState -> EditTodoRequest -> Handler T.Text
editTodos (ToodlesState ref _) req = do
(TodoListResult r _) <- liftIO $ readIORef ref
let editedList =
map
(\t ->
if willEditTodo req t
then editTodo req t
else t)
r
editedFilteredList = filter (willEditTodo req) editedList
_ <- mapM_ recordUpdates editedFilteredList
return "{}"
where
willEditTodo :: EditTodoRequest -> TodoEntry -> Bool
willEditTodo editRequest entry = Main.id entry `elem` editIds editRequest
editTodo :: EditTodoRequest -> TodoEntry -> TodoEntry
editTodo editRequest entry =
let newAssignee = if isJust (setAssignee editRequest) && (not . T.null . fromJust $ setAssignee editRequest)
then setAssignee editRequest
else assignee entry
newPriority = if isJust (setPriority editRequest) then setPriority editRequest else priority entry in
entry {assignee = newAssignee,
tags = tags entry ++ addTags editRequest,
priority = newPriority,
customAttributes = nub $ customAttributes entry ++ addKeyVals editRequest}
recordUpdates :: MonadIO m => TodoEntry -> m ()
recordUpdates t = void $ updateTodoLinesInFile renderTodo t
renderTodo :: TodoEntry -> [T.Text]
renderTodo t =
let comment =
fromJust $ lookup ("." <> getExtension (sourceFile t)) fileTypeToComment
detail =
"TODO (" <>
(T.pack $
Data.String.Utils.join
"|"
(map T.unpack $ [fromMaybe "" $ assignee t] ++
listIfNotNull (fmap (T.pack . maybe "" ((\n -> "p=" ++ n) . show)) priority t) ++
tags t ++
map (\a -> fst a <> "=" <> snd a) (customAttributes t))) <>
") "
fullNoComments = mapHead (\l -> detail <> "- " <> l) $ body t
commented = map (\l -> comment <> " " <> l) fullNoComments in
mapHead (\l -> leadingText t <> l) $
mapInit (\l -> foldl (<>) "" [" " | _ <- [1..(T.length $ leadingText t)]] <> l) commented
mapHead :: (a -> a) -> [a] -> [a]
mapHead f (x:xs) = f x : xs
mapHead _ xs = xs
mapInit :: (a -> a) -> [a] -> [a]
mapInit f (x:xs) = [x] ++ map f xs
mapInit _ x = x
listIfNotNull :: T.Text -> [T.Text]
listIfNotNull "" = []
listIfNotNull s = [s]
root :: ToodlesState -> [T.Text] -> Handler BZ.Html
root (ToodlesState _ dPath) path =
if null path then
liftIO $ BZ.preEscapedToHtml <$> readFile (dPath ++ "/html/index.html")
else throwError $ err404 { errBody = "Not found" }
app :: ToodlesState -> Application
app s = serve toodlesAPI $ server s
isEntryHead :: TodoEntry -> Bool
isEntryHead TodoEntryHead {} = True
isEntryHead _ = False
isBodyLine :: TodoEntry -> Bool
isBodyLine (TodoBodyLine _) = True
isBodyLine _ = False
combineTodo :: TodoEntry -> TodoEntry -> TodoEntry
combineTodo (TodoEntryHead i b a p n entryPriority attrs entryTags entryLeadingText) (TodoBodyLine l) =
TodoEntryHead i (b ++ [l]) a p n entryPriority attrs entryTags entryLeadingText
combineTodo _ _ = error "Can't combine todoEntry of these types"
data SourceFile = SourceFile
{ fullPath :: FilePath
, sourceLines :: [T.Text]
} deriving (Show)
newtype AssigneeFilterRegex =
AssigneeFilterRegex T.Text
deriving (Show, Data, Eq)
newtype SearchFilter =
AssigneeFilter AssigneeFilterRegex
deriving (Show, Data, Eq)
data ToodlesArgs = ToodlesArgs
{ directory :: FilePath
, assignee_search :: Maybe SearchFilter
, limit_results :: Int
, port :: Maybe Int
, no_server :: Bool
} deriving (Show, Data, Typeable, Eq)
argParser :: ToodlesArgs
argParser =
ToodlesArgs
{ directory = def &= typFile &= help "Root directory of your project"
, assignee_search = def &= help "Filter todo's by assignee"
, limit_results = def &= help "Limit number of search results"
, port = def &= help "Run server on port"
, no_server = def &= help "Output matching todos to the command line and exit"
} &=
summary ("toodles " ++ showVersion version) &=
program "toodles" &=
verbosity &=
help "Manage TODO's directly from your codebase"
fileTypeToComment :: [(T.Text, T.Text)]
fileTypeToComment =
[ (".c", "//")
, (".clj", ";;")
, (".cpp", "//")
, (".cs", "//")
, (".ex", "#")
, (".erl", "%")
, (".go", "//")
, (".hs", "--")
, (".java", "//")
, (".js", "//")
, (".kt", "//")
, (".kts", "//")
, (".lua", "--")
, (".m", "//")
, (".php", "//")
, (".proto", "//")
, (".py", "#")
, (".rb", "#")
, (".rs", "//")
, (".scala", "//")
, (".sh", "#")
, (".swift", "///")
, (".ts", "//")
, (".txt", "")
, (".yaml", "#")
]
unkownMarker :: T.Text
unkownMarker = "UNKNOWN-DELIMETER-UNKNOWN-DELIMETER-UNKNOWN-DELIMETER"
getCommentForFileType :: T.Text -> T.Text
getCommentForFileType extension =
fromMaybe unkownMarker $ lookup adjustedExtension fileTypeToComment
where
adjustedExtension =
if T.isPrefixOf "." extension
then extension
else "." <> extension
type Parser = Parsec Void T.Text
lexeme :: Parser a -> Parser a
lexeme = L.lexeme space
symbol :: T.Text -> Parser T.Text
symbol = L.symbol space
parseComment :: T.Text -> Parser TodoEntry
parseComment extension
= do
_ <- manyTill anyChar (symbol $ getCommentForFileType extension)
b <- many anyChar
return . TodoBodyLine $ T.pack b
integer :: Parser Integer
integer = lexeme $ L.signed space L.decimal
parsePriority :: Parser Integer
parsePriority = do
_ <- symbol "p="
integer
parseAssignee :: Parser String
parseAssignee = many (noneOf [')', '|', '='])
-- TODO(avi|p=3|#cleanup) - fix and type this better
parseDetails ::
T.Text -> (Maybe T.Text, Maybe T.Text, [(T.Text, T.Text)], [T.Text])
parseDetails toParse =
let dataTokens = T.splitOn "|" toParse
assigneeTo =
find
(\t ->
not (T.null t) &&
not (T.isInfixOf "=" t) && not (T.isPrefixOf "#" t))
dataTokens
allDetails =
map (\[a, b] -> (a, b)) $ filter (\t -> length t == 2) $
map (T.splitOn "=") dataTokens
priorityVal = snd <$> find (\t -> T.strip (fst t) == "p") allDetails
filteredDetails = filter (\t -> T.strip (fst t) /= "p") allDetails
entryTags = filter (T.isPrefixOf "#") dataTokens
in (assigneeTo, priorityVal, filteredDetails, entryTags)
inParens :: Parser a -> Parser a
inParens = between (symbol "(") (symbol ")")
stringToMaybe :: T.Text -> Maybe T.Text
stringToMaybe t =
if T.null t
then Nothing
else Just t
fst4 :: (a, b, c, d) -> a
fst4 (x, _, _, _) = x
snd4 :: (a, b, c, d) -> b
snd4 (_, x, _, _) = x
thd4 :: (a, b, c, d) -> c
thd4 (_, _, x, _) = x
fth4 :: (a, b, c, d) -> d
fth4 (_, _, _, x) = x
prefixParserForFileType extension =
let comment = symbol . getCommentForFileType $ extension
orgMode =
(try $ symbol "****") <|> (try $ symbol "***") <|> (try $ symbol "**") <|>
(try $ symbol "*") <|>
(symbol "-")
in if extension == "org"
then orgMode
else comment
parseTodoEntryHead :: FilePath -> LineNumber -> Parser TodoEntry
parseTodoEntryHead path lineNum = do
entryLeadingText <- manyTill anyChar (prefixParserForFileType $ getExtension path)
_ <- symbol "TODO"
entryDetails <- optional $ try (inParens $ many (noneOf [')', '(']))
let parsedDetails = parseDetails . T.pack <$> entryDetails
entryPriority = (readMaybe . T.unpack) =<< (snd4 =<< parsedDetails)
otherDetails = maybe [] thd4 parsedDetails
entryTags = maybe [] fth4 parsedDetails
_ <- optional $ symbol "-"
_ <- optional $ symbol ":"
b <- many anyChar
return $
TodoEntryHead
0
[T.pack b]
(stringToMaybe . T.strip $ fromMaybe "" (fst4 =<< parsedDetails))
path
lineNum
entryPriority
otherDetails
entryTags
(T.pack entryLeadingText)
parseTodo :: FilePath -> LineNumber -> Parser TodoEntry
parseTodo path lineNum =
try (parseTodoEntryHead path lineNum) <|> parseComment (getExtension path)
getAllFiles :: ToodlesConfig -> FilePath -> IO [SourceFile]
getAllFiles config path =
E.catch
(do putStrLn $ printf "Running toodles for path: %s" path
files <- recurseDir SystemFS path
let validFiles = filter (isValidFile config) files
-- TODO(avi|p=3|#cleanup) - make sure it's a file first
mapM
(\f ->
SourceFile f . (map T.pack . lines) <$>
E.catch
(SIO.readFile f)
(\(e :: E.IOException) -> print e >> return ""))
validFiles)
(\(e :: E.IOException) ->
putStrLn ("Error reading " ++ path ++ ": " ++ show e) >> return [])
fileHasValidExtension :: FilePath -> Bool
fileHasValidExtension path =
any (\ext -> ext `T.isSuffixOf` T.pack path) (map fst fileTypeToComment)
ignoreFile :: ToodlesConfig -> FilePath -> Bool
ignoreFile (ToodlesConfig ignoredPaths) file =
let p = T.pack file
in T.isInfixOf "node_modules" p || T.isSuffixOf "pb.go" p ||
T.isSuffixOf "_pb2.py" p ||
any (\r -> file =~ r :: Bool) ignoredPaths
getExtension :: FilePath -> T.Text
getExtension path = last $ T.splitOn "." (T.pack path)
isValidFile :: ToodlesConfig -> FilePath -> Bool
isValidFile config f =
fileHasValidExtension f && not (ignoreFile config f)
runTodoParser :: SourceFile -> [TodoEntry]
runTodoParser (SourceFile path ls) =
let parsedTodoLines =
map
(\(lineNum, lineText) -> parseMaybe (parseTodo path lineNum) lineText)
(zip [1 ..] ls)
groupedTodos = foldl foldTodoHelper ([], False) parsedTodoLines
in fst groupedTodos
-- fold fn to concatenate todos that a multiple, single line comments
foldTodoHelper :: ([TodoEntry], Bool) -> Maybe TodoEntry -> ([TodoEntry], Bool)
foldTodoHelper (todoEntries :: [TodoEntry], currentlyBuildingTodoLines :: Bool) maybeTodo
-- We're not on a todo line, keep going
| isNothing maybeTodo = (todoEntries, False)
-- We see the start of a new todo
| isEntryHead $ fromJust maybeTodo = (todoEntries ++ [fromJust maybeTodo], True)
-- We a body line of a todo to concatenate to the current one
| isBodyLine (fromJust maybeTodo) && currentlyBuildingTodoLines =
(init todoEntries ++ [combineTodo (last todoEntries) (fromJust maybeTodo)], True)
| otherwise = (todoEntries, False)
prettyFormat :: TodoEntry -> String
prettyFormat (TodoEntryHead _ l a p n entryPriority _ _ _) =
printf
"Assignee: %s\n%s%s:%d\n%s"
(fromMaybe "None" a)
(maybe "" (\x -> "Priority: " ++ show x ++ "\n") entryPriority)
p
n
(unlines $ map T.unpack l)
prettyFormat (TodoBodyLine _) = error "Invalid type for prettyFormat"
filterSearch :: Maybe SearchFilter -> (TodoEntry -> Bool)
filterSearch Nothing = const True
filterSearch (Just (AssigneeFilter (AssigneeFilterRegex query))) =
\entry -> fromMaybe "" (assignee entry) == query
limitSearch :: [TodoEntry] -> Int -> [TodoEntry]
limitSearch todoList limit =
if limit == 0
then todoList
else take limit todoList
runFullSearch :: ToodlesArgs -> IO TodoListResult
runFullSearch userArgs =
let projectRoot = directory userArgs
in do
configExists <- doesFileExist $ projectRoot ++ "/.toodles.yaml"
config <- if configExists
then Y.decodeFileEither (projectRoot ++ "/.toodles.yaml")
else return . Right $ ToodlesConfig []
when (isLeft config)
$ putStrLn $ "[WARNING] Invalid .toodles.yaml: " ++ show config
allFiles <- getAllFiles (fromRight (ToodlesConfig []) config) projectRoot
let parsedTodos = concatMap runTodoParser allFiles
filteredTodos =
filter (filterSearch (assignee_search userArgs)) parsedTodos
resultList = limitSearch filteredTodos $ limit_results userArgs
indexedResults =
map (\(i, r) -> r {Main.id = i}) $ zip [1 ..] resultList
return $ TodoListResult indexedResults ""
getFullSearchResults :: ToodlesState -> Bool -> IO TodoListResult
getFullSearchResults (ToodlesState ref _) recompute =
if recompute
then do
putStrLn "refreshing todo's"
userArgs <- cmdArgs argParser >>= setAbsolutePath
sResults <- runFullSearch userArgs
atomicModifyIORef' ref (const (sResults, sResults))
else putStrLn "cached read" >> readIORef ref
showRawFile :: ToodlesState -> Integer -> Handler BZ.Html
showRawFile (ToodlesState ref _) entryId = do
(TodoListResult r _) <- liftIO $ readIORef ref
let entry = find (\t -> Main.id t == entryId) r
liftIO $
maybe
(return "Not found")
(\e -> addAnchors <$> readFile (sourceFile e))
entry
addAnchors :: String -> BZ.Html
addAnchors s =
let codeLines = zip [1 ..] $ lines s
in BZ.preEscapedToHtml $
(unlines $
map
(\(i, l) -> printf "<pre><a name=\"line-%s\">%s</a></pre>" (show i) l)
codeLines)
setAbsolutePath :: ToodlesArgs -> IO ToodlesArgs
setAbsolutePath toodlesArgs =
let pathOrDefault =
if T.null . T.pack $ directory toodlesArgs
then "."
else directory toodlesArgs
in do absolute <- normalise_path <$> absolute_path pathOrDefault
return $ toodlesArgs {directory = absolute}
main :: IO ()
main = do
userArgs <- cmdArgs argParser >>= setAbsolutePath
sResults <- runFullSearch userArgs
case userArgs of
(ToodlesArgs _ _ _ _ True) -> mapM_ (putStrLn . prettyFormat) $ todos sResults
_ -> do
let webPort = fromMaybe 9001 $ port userArgs
ref <- newIORef sResults
dataDir <- (++ "/web") <$> getDataDir
putStrLn $ "serving on " ++ show webPort
run webPort $ app $ ToodlesState ref dataDir