packages feed

toodles-0.1.0.0: app/Main.hs

{-# LANGUAGE DataKinds           #-}
{-# LANGUAGE DeriveDataTypeable  #-}
{-# LANGUAGE DeriveGeneric       #-}
{-# LANGUAGE OverloadedStrings   #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeOperators       #-}

-- TODO(avi|p=3|#techdebt) - break this into modules
module Main where

import qualified Control.Exception          as E
import           Control.Monad
import           Control.Monad.IO.Class
import           Data.Aeson
import qualified Data.ByteString.Lazy.Char8 as B8S
import           Data.IORef
import           Data.List
import           Data.Maybe
import           Data.Either
import           Data.Monoid
import           Data.Proxy
import           Data.String.Utils
import qualified Data.Text                  as T
import           Data.Version               (showVersion)
import           Data.Void
import           Debug.Trace
import           GHC.Generics
import           Network.HTTP.Types         (status200)
import           Network.Wai
import           Network.Wai.Handler.Warp
import           Paths_toodles              (version)
import           Servant
import           Servant.HTML.Blaze
import           System.Console.CmdArgs
import           System.IO.HVFS
import           System.Directory
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
import Paths_toodles
import qualified Data.Yaml as Y

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)

data DeleteTodoRequest = DeleteTodoRequest
  { ids :: [Integer]
  } deriving (Show, Generic)

data EditTodoRequest = EditTodoRequest
  { editIds     :: [Integer]
  , setAssignee :: Maybe T.Text
  , addTags     :: [T.Text]
  , setPriority :: Maybe Integer
  } deriving (Show, Generic)

data 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 from to xs = take (to - from + 1) (drop from 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 :: [TodoEntry] -> IO [TodoEntry]
removeAndAdjust deleteList =
  if null deleteList
    then return []
    else let deleteItem = head deleteList
             rest = tail deleteList
         in do _ <- removeTodoFromCode deleteItem
               return $
                 map
                   (\t ->
                      if (sourceFile t == sourceFile deleteItem) &&
                         (lineNumber t > lineNumber deleteItem)
                        then t
                             { lineNumber =
                                 (lineNumber t) -
                                 (fromIntegral . length $ body deleteItem)
                             }
                        else t)
                   rest

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 -> not $ Main.id t `elem` (map Main.id toDelete)) r)
        }
  _ <-
    liftIO $ atomicModifyIORef' ref (const (updeatedResults, updeatedResults))
  return $ T.pack "{}"

editTodos :: ToodlesState -> EditTodoRequest -> Handler T.Text
editTodos (ToodlesState ref _) req = do
  refVal@(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 $ T.pack "{}"
  where
    willEditTodo :: EditTodoRequest -> TodoEntry -> Bool
    willEditTodo req entry = Main.id entry `elem` editIds req

    editTodo :: EditTodoRequest -> TodoEntry -> TodoEntry
    editTodo req entry =
      let newAssignee = if ((isJust $ setAssignee req) && (not . T.null . fromJust $ setAssignee req)) then setAssignee req else assignee entry
          newPriority = if isJust (setPriority req) then setPriority req else priority entry in

        entry {assignee = newAssignee, tags = tags entry ++ (addTags req), priority = newPriority}

    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 =
        (T.pack "TODO(") <>
        (T.pack $
         Data.String.Utils.join
           "|"
           (map T.unpack $ [fromMaybe "" $ assignee t] ++
            listIfNotNull (fmap (\p -> (T.pack (maybe "" ((\n -> "p=" ++ n) . show) p))) priority t) ++
            (tags t) ++
            (map (\a -> fst a <> "=" <> snd a)) (customAttributes t))) <>
        (T.pack ") ")
      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 "" = []
listIfNotNull s = [s]

root :: ToodlesState -> [T.Text] -> Handler BZ.Html
root _ path =
  if null path then
    liftIO $ BZ.preEscapedToHtml <$> readFile "./web/html/index.html"
  else throwError $ err404 { errBody = "Not found" }

testroot :: ToodlesState -> [T.Text] -> Handler T.Text
testroot _ _ =
  return "Hello"

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 priority attrs tags leadingText) (TodoBodyLine l) =
  TodoEntryHead i (b ++ [l]) a p n priority attrs tags leadingText
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)

data SearchFilter =
  AssigneeFilter AssigneeFilterRegex
  deriving (Show, Data, Eq)

data ToodlesArgs = ToodlesArgs
  { directory       :: FilePath
  , assignee_search :: Maybe SearchFilter
  , limit_results   :: Int
  , port            :: Maybe Int
  } 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"
  } &=
  summary ("toodles " ++ showVersion version) &=
  program "toodles" &=
  verbosity &=
  help "Manage TODO's directly from your codebase"

fileTypeToComment :: [(T.Text, T.Text)]
fileTypeToComment =
  [ (".c", "//")
  , (".clj", ";;")
  , (".cpp", "//")
  , (".ex", "#")
  , (".erl", "%")
  , (".go", "//")
  , (".hs", "--")
  , (".java", "//")
  , (".js", "//")
  , (".m", "//")
  , (".proto", "//")
  , (".py", "#")
  , (".rb", "#")
  , (".rs", "//")
  , (".scala", "//")
  , (".sh", "#")
  , (".swift", "///")
  , (".ts", "//")
  , (".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|#techdebt) - fix and type this better
parseDetails ::
     T.Text -> (Maybe T.Text, Maybe T.Text, [(T.Text, T.Text)], [T.Text])
parseDetails toParse =
  let tokens = T.splitOn "|" toParse
      assigneeTo =
        find
          (\t ->
             (not (T.null t)) &&
             (not (T.isInfixOf "=" t) && (not (T.isPrefixOf "#" t))))
          tokens
      allDetails =
        map (\[a, b] -> (a, b)) $ filter (\t -> length t == 2) $
        map (T.splitOn "=") tokens
      priority = snd <$> (find (\t -> (T.strip $ fst t) == "p") allDetails)
      filteredDetails = filter (\t -> (T.strip $ fst t) /= "p") allDetails
      tags = filter (\t -> T.isPrefixOf "#" t) tokens
  in (assigneeTo, priority, filteredDetails, tags)

inParens = between (symbol "(") (symbol ")")

stringToMaybe t =
  if T.null t
    then Nothing
    else Just t

fst4 (x, _, _, _) = x

snd4 (_, x, _, _) = x

thd4 (_, _, x, _) = x

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
  leadingText <- manyTill anyChar (prefixParserForFileType $ getExtension path)
  _ <- symbol "TODO"
  details <- optional $ try (inParens $ many (noneOf [')', '(']))
  let parsedDetails = parseDetails . T.pack <$> details
      priority = (readMaybe . T.unpack) =<< (snd4 =<< parsedDetails)
      otherDetails = maybe [] thd4 parsedDetails
      tags = maybe [] fth4 parsedDetails
  _ <- optional $ symbol "-"
  _ <- optional $ symbol ":"
  b <- many anyChar
  return $
    TodoEntryHead
      0
      [T.pack b]
      (stringToMaybe . T.strip $ fromMaybe "" (fst4 =<< parsedDetails))
      path
      lineNum
      priority
      otherDetails
      tags
      (T.pack leadingText)

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|#techdebt) - 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 (\p -> file =~ p) 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

foldTodoHelper :: ([TodoEntry], Bool) -> Maybe TodoEntry -> ([TodoEntry], Bool)
foldTodoHelper (todos :: [TodoEntry], currentlyBuildingTodoLines :: Bool) maybeTodo
  | isNothing maybeTodo = (todos, False)
  | isEntryHead $ fromJust maybeTodo = (todos ++ [fromJust maybeTodo], True)
  | isBodyLine (fromJust maybeTodo) && currentlyBuildingTodoLines =
    (init todos ++ [combineTodo (last todos) (fromJust maybeTodo)], True)
  | otherwise = (todos, False)

prettyFormat :: TodoEntry -> String
prettyFormat (TodoEntryHead _ l a p n priority _ _ _) =
  printf
    "Assignee: %s\n%s%s:%d\n%s"
    (fromMaybe "None" a)
    (maybe "" (\x -> "Priority: " ++ show x ++ "\n") priority)
    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 results limit =
  if limit == 0
    then results
    else take limit results

runFullSearch :: ToodlesArgs -> IO TodoListResult
runFullSearch userArgs =
  let projectRoot = directory userArgs
  in do
        configExists <- doesFileExist $ projectRoot ++ "/.toodles.yaml"
        config <- if configExists
          then eitherDecode' . B8S.pack <$> readFile (projectRoot ++ "/.toodles.yaml")
          else return . Right $ ToodlesConfig []
        allFiles <- getAllFiles (fromRight (ToodlesConfig []) config) projectRoot
        let parsedTodos = concatMap runTodoParser allFiles
            filteredTodos =
              filter (filterSearch (assignee_search userArgs)) parsedTodos
            results = limitSearch filteredTodos $ limit_results userArgs
            indexedResults =
              map (\(i, r) -> r {Main.id = i}) $ zip [1 ..] results
        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
      writtenResults <- atomicModifyIORef' ref (const (sResults, sResults))
      return writtenResults
    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 sourceLines = zip [1 ..] $ lines s
  in BZ.preEscapedToHtml $
     (unlines $
      map
        (\(i, l) -> printf "<pre><a name=\"line-%s\">%s</a></pre>" (show i) l)
        sourceLines)

setAbsolutePath :: ToodlesArgs -> IO ToodlesArgs
setAbsolutePath args =
  let pathOrDefault =
        if T.null . T.pack $ directory args
          then "."
          else directory args
  in do absolute <- normalise_path <$> absolute_path pathOrDefault
        return $ args {directory = absolute}

main :: IO ()
main = do
  userArgs <- cmdArgs argParser >>= setAbsolutePath
  sResults <- runFullSearch userArgs
  if isJust $ port userArgs
    then do
      let webPort = fromJust $ port userArgs
      ref <- newIORef sResults
      dataDir <- (++ "/web") <$> getDataDir
      putStrLn $ "serving on " ++ show webPort
      run webPort $ app $ ToodlesState ref dataDir
    else mapM_ (putStrLn . prettyFormat) $ todos sResults