packages feed

toodles-1.0.0: src/Parse.hs

{-# LANGUAGE OverloadedStrings   #-}
{-# LANGUAGE ScopedTypeVariables #-}

module Parse where

import           Types


import           Data.Functor
import           Data.List                  (find)
import           Data.Maybe                 (fromJust, fromMaybe, isJust,
                                             isNothing)
import           Data.Text                  (Text)
import qualified Data.Text                  as T
import           Data.Void                  (Void)
import           Text.Megaparsec
import           Text.Megaparsec.Char
import qualified Text.Megaparsec.Char.Lexer as L
import           Text.Printf
import           Text.Read

type Parser = Parsec Void Text

symbol :: Text -> Parser Text
symbol = L.symbol space

parseComment :: TodoParserState -> Text -> Parser TodoEntry
parseComment state fileExtension =
  if state == ParseStateMultiLineComment
    then do
      let closingParser = symbol $ getMultiClosingForFileType fileExtension
      lineWithClosing <- optional . try $ manyTill anyChar closingParser
      lineWithOutClosing <- optional $ many anyChar
      return $ TodoBodyLine (T.pack (fromMaybe (fromJust lineWithOutClosing) lineWithOutClosing)) False (isJust lineWithClosing)
    else do
      single <- optional . try $ manyTill anyChar (symbol $ getCommentForFileType fileExtension)
      multi <-
        if isJust single
        then return Nothing
        else
          optional . try $ manyTill anyChar (symbol $ getMultiOpeningForFileType fileExtension)
      if isJust single || isJust multi
        then do
          b <- many anyChar
          return $ TodoBodyLine (T.pack b) (isJust  multi) (getMultiClosingForFileType fileExtension `T.isInfixOf` T.pack b)
        else
          fail "No open comment marker found"

getCommentForFileType :: Text -> Text
getCommentForFileType fileExtension =
  getCommentForFileTypeWithDefault singleCommentStart fileExtension

getMultiClosingForFileType :: Text -> Text
getMultiClosingForFileType fileExtension =
  getCommentForFileTypeWithDefault multiLineClose fileExtension

getMultiOpeningForFileType :: Text -> Text
getMultiOpeningForFileType fileExtension =
  getCommentForFileTypeWithDefault multiLineOpen fileExtension

getCommentForFileTypeWithDefault :: (FileTypeDetails -> Maybe Text) -> Text -> Text
getCommentForFileTypeWithDefault getter fileExtension =
    fromMaybe unkownMarker (getter =<< find (\a -> extension a == adjustedExtension) fileTypeToComment)
    where
    adjustedExtension =
        if T.isPrefixOf "." fileExtension
            then fileExtension
            else "." <> fileExtension

integer :: Parser Integer
integer = lexeme $ L.signed space L.decimal

    where
    lexeme :: Parser a -> Parser a
    lexeme = L.lexeme space

parseAssignee :: Parser String
parseAssignee = many (noneOf [')', '|', '='])

-- TODO(avi|p=3|#cleanup) - fix and type this better
parseDetails :: Text -> (Maybe Text, Maybe Text, [(Text, Text)], [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)

-- | parse "hard-coded" flags, and user-defined flags if any
parseFlag :: [UserFlag] -> Parser Flag
parseFlag = foldr (\a b -> b <|> foo a) (try parseFlagHardcoded)
  where
    foo :: UserFlag -> Parser Flag
    foo (UserFlag x) = try (symbol x $> (UF $ UserFlag x))

-- | parse flags TODO, FIXME, XXX
parseFlagHardcoded :: Parser Flag
parseFlagHardcoded =
      try (symbol "TODO"  $> TODO )
  <|> try (symbol "FIXME" $> FIXME)
  <|>     (symbol "XXX"   $> XXX  )

newtype MultineCommentEnclosing = MultineCommentEnclosing (Text, Text)

data FileTypeDetails = FileTypeDetails
  { extension          :: Text
  , singleCommentStart :: Maybe Text
  , multilineEnclosing :: Maybe MultineCommentEnclosing
  }

multiLineClose :: FileTypeDetails -> Maybe Text
multiLineClose (FileTypeDetails _ _ (Just (MultineCommentEnclosing (_, b)))) = Just b
multiLineClose _ = Nothing

multiLineOpen :: FileTypeDetails -> Maybe Text
multiLineOpen (FileTypeDetails _ _ (Just (MultineCommentEnclosing (a, _)))) = Just a
multiLineOpen _ = Nothing

enclosing :: (Text, Text) -> Maybe MultineCommentEnclosing
enclosing = Just . MultineCommentEnclosing

slashStar :: Maybe MultineCommentEnclosing
slashStar = enclosing ("/*", "*/")

fileTypeToComment :: [FileTypeDetails]
fileTypeToComment =
  [ FileTypeDetails ".c" (Just "//") slashStar
  , FileTypeDetails ".cc" (Just "//") slashStar
  , FileTypeDetails ".clj" (Just ";;") Nothing
  , FileTypeDetails ".cpp" (Just "//") slashStar
  , FileTypeDetails ".cxx" (Just "//") slashStar
  , FileTypeDetails ".c++" (Just "//") slashStar
  , FileTypeDetails ".cs" (Just "//") slashStar
  , FileTypeDetails ".css" Nothing slashStar
  , FileTypeDetails ".ex" (Just "#") Nothing
  , FileTypeDetails ".erl" (Just "%") Nothing
  , FileTypeDetails ".go" (Just "//") slashStar
  , FileTypeDetails ".h" (Just "//") slashStar
  , FileTypeDetails ".hh" (Just "//") slashStar
  , FileTypeDetails ".hpp" (Just "//") slashStar
  , FileTypeDetails ".hs" (Just "--") (enclosing ("{-", "-}"))
  , FileTypeDetails ".html" Nothing (enclosing ("<!--", "-->"))
  , FileTypeDetails ".hxx" (Just "//") slashStar
  , FileTypeDetails ".h++" (Just "//") slashStar
  , FileTypeDetails ".java" (Just "//") slashStar
  , FileTypeDetails ".js" (Just "//") slashStar
  , FileTypeDetails ".jsx" (Just "//") slashStar
  , FileTypeDetails ".kt" (Just "//") slashStar
  , FileTypeDetails ".kts" (Just "//") slashStar
  , FileTypeDetails ".lua" (Just "--") (enclosing ("--[[", "]]"))
  , FileTypeDetails ".m" (Just "//") slashStar
  , FileTypeDetails ".php" (Just "//") slashStar
  , FileTypeDetails ".proto" (Just "//") slashStar
  , FileTypeDetails ".py" (Just "#") (enclosing ("\"\"\"", "\"\"\""))
  , FileTypeDetails ".rb" (Just "#") (enclosing ("=begin", "=end"))
  , FileTypeDetails ".rs" (Just "//") slashStar
  , FileTypeDetails ".scss" Nothing slashStar
  , FileTypeDetails ".scala" (Just "//") slashStar
  , FileTypeDetails ".sh" (Just "#") slashStar
  , FileTypeDetails ".swift" (Just "///") slashStar
  , FileTypeDetails ".ts" (Just "//") slashStar
  , FileTypeDetails ".tsx" (Just "//") slashStar
  , FileTypeDetails ".txt" (Just "") Nothing
  , FileTypeDetails ".vue" (Just "//") slashStar
  , FileTypeDetails ".yaml" (Just "#") Nothing
  ]

singleLineCommentForExtension :: Text -> Maybe Text
singleLineCommentForExtension fileExtension =
  find (\f -> extension f == fileExtension) fileTypeToComment >>= singleCommentStart

multiLineOpenCommentForExtension :: Text -> Maybe Text
multiLineOpenCommentForExtension fileExtension =
  find (\f -> extension f == fileExtension) fileTypeToComment >>=
  multilineEnclosing >>=
  (\(MultineCommentEnclosing a) -> return $ fst a)

-- Higher order function returning our folder.
fileParseFoldFn ::
  -- partial fn params
     [UserFlag]
  -> FilePath
  -- returns a fold function of:
  -> (TodoParserState, [Maybe TodoEntry])
  -> (Integer, Text)
  -> (TodoParserState, [Maybe TodoEntry])
fileParseFoldFn userFlags file (currentLineState, pastList) (lineNum, line) =
  let parsedLine = parseMaybe (parseTodo currentLineState userFlags file lineNum) line
      newState = nextState currentLineState parsedLine in
  (newState, pastList ++ [parsedLine])

nextState :: TodoParserState -> Maybe TodoEntry -> TodoParserState
nextState _ Nothing                            = ParseStateUnknown
nextState _ (Just (TodoBodyLine _ _ True))     = ParseStateUnknown
nextState _ (Just (TodoBodyLine _ True False)) = ParseStateMultiLineComment
nextState s (Just (TodoBodyLine _ False False)) = s
nextState _ (Just (TodoEntryHead _ _ _ _ _ _ _ _ _ _ _ True False)) = ParseStateMultiLineComment
nextState _ (Just (TodoEntryHead _ _ _ _ _ _ _ _ _ _ MultiLine _ False)) = ParseStateMultiLineComment
nextState _ (Just TodoEntryHead {}) = ParseStateUnknown
nextState a b = error ("No next state for " ++ show a ++ "   " ++ show b)

runTodoParser :: [UserFlag] -> SourceFile -> [TodoEntry]
runTodoParser us (SourceFile path ls) =
  let parsedTodoLines =
        foldl
          (fileParseFoldFn
          us
          path)
          (ParseStateUnknown, [])
          (zip [1 ..] ls)
      groupedTodos = foldl foldTodoHelper ([], False) (snd parsedTodoLines)
  in fst groupedTodos

  where
    -- fold fn to concatenate todos that a multiple, single line comments
    foldTodoHelper :: ([TodoEntry], Bool) -> Maybe TodoEntry -> ([TodoEntry], Bool)
    foldTodoHelper (todoEntries, currentlyBuildingTodoLines) 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)

          where
            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 f attrs entryTags entryLeadingText t isOpened _) (TodoBodyLine l _ isClosed) =
                TodoEntryHead i (b ++ [l]) a p n entryPriority f attrs entryTags entryLeadingText t isOpened isClosed
            combineTodo  (TodoBodyLine l isOpened _) (TodoEntryHead i b a p n entryPriority f attrs entryTags entryLeadingText t _ isClosed) =
                TodoEntryHead i (l : b) a p n entryPriority f attrs entryTags entryLeadingText t isOpened isClosed
            combineTodo (TodoBodyLine l isOpened _) (TodoBodyLine r _ isClosed) =
              TodoBodyLineCombined (l : [r]) isOpened isClosed
            combineTodo (TodoBodyLineCombined l isOpened _) (TodoBodyLine r _ isClosed) =
              TodoBodyLineCombined (l ++ [r]) isOpened isClosed
            combineTodo (TodoBodyLineCombined l isOpened _) (TodoEntryHead i b a p n entryPriority f attrs entryTags entryLeadingText t _ isClosed) =
                TodoEntryHead i (l ++ b) a p n entryPriority f attrs entryTags entryLeadingText t isOpened isClosed
            combineTodo _ _ = error "Can't combine todoEntry of these types"

getExtension :: FilePath -> Text
getExtension path = last $ T.splitOn "." (T.pack path)

stringToMaybe :: Text -> Maybe 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

unkownMarker :: Text
unkownMarker = "UNKNOWN-DELIMETER-UNKNOWN-DELIMETER-UNKNOWN-DELIMETER"

data TodoParserState
  = ParseStateUnknown
  | ParseStateSource
  | ParseStateSingleComment
  | ParseStateMultiLineComment deriving (Eq, Show)

data LeadingTextKind = SingleLT | MultiLT | NonOpenedComment deriving (Eq, Show)

takeShorter :: String -> String -> String
takeShorter singleLeadingText multiLeadingText
  | length singleLeadingText == length multiLeadingText = ""
  | null singleLeadingText                              = multiLeadingText
  | null multiLeadingText                               = singleLeadingText
  | length singleLeadingText > length multiLeadingText  = multiLeadingText
  | length singleLeadingText < length multiLeadingText  = singleLeadingText
  | otherwise  = ""

parseTodo :: TodoParserState -> [UserFlag] -> FilePath -> LineNumber -> Parser TodoEntry
parseTodo state us path lineNum = try (parseTodoEntryHead us)
                            <|> parseComment state (getExtension path)
  where
    parseTodoEntryHead :: [UserFlag] -> Parser TodoEntry
    parseTodoEntryHead uf =
      if state == ParseStateMultiLineComment
        then do
          leadingTextMulti <- optional (try $ many spaceChar)
          parseEntryHead NonOpenedComment (fromMaybe "" leadingTextMulti)
        else do
          entryLeadingTextSingle <- optional (try (manyTill anyChar (lookAhead . prefixParserForFileType $ getExtension path)))
          entryLeadingTextMulti <-
            if isNothing entryLeadingTextSingle
            then
              optional (manyTill anyChar (lookAhead . multiPrefixParserForFileType $ getExtension path))
            else
              return Nothing

          commentOpenSingle <- optional . try $ prefixParserForFileType $ getExtension path
          commentOpenMulti <- optional . try $ multiPrefixParserForFileType $ getExtension path
          let leadingTextKind = case (commentOpenSingle, commentOpenMulti) of
                (Just _, Nothing)  -> SingleLT
                (Nothing, Just _)  -> MultiLT
                (Nothing, Nothing) -> NonOpenedComment
                err                -> error . printf "Error: unexpected value in leading text pattern match: %s. Please report this bug https://github.com/aviaviavi/toodles" $ show err

              -- select the shorter leading text, and update leadingTextKind enum accordingly
              matchingLeadingText = takeShorter (fromMaybe "" entryLeadingTextSingle) (fromMaybe "" entryLeadingTextMulti)

          parseEntryHead leadingTextKind matchingLeadingText

          where

          parseEntryHead leadingTextCharsKind leadingTextChars = do
            f <- parseFlag uf
            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 ":"
            let closingParser = symbol $ getMultiClosingForFileType (getExtension path)
            lineWithClosing <- optional . try $ manyTill anyChar closingParser
            lineWithOutClosing <- optional $ many anyChar
            return $
                TodoEntryHead
                0
                [T.pack (fromMaybe (fromJust lineWithOutClosing) lineWithClosing)]
                (stringToMaybe . T.strip $ fromMaybe "" (fst4 =<< parsedDetails))
                path
                lineNum
                entryPriority
                f
                otherDetails
                entryTags
                (T.pack leadingTextChars)
                (if leadingTextCharsKind == SingleLT then SingleLine else MultiLine)
                (leadingTextCharsKind == MultiLT)
                (isJust lineWithClosing)

          inParens :: Parser a -> Parser a
          inParens = between (symbol "(") (symbol ")")

          prefixParserForFileType :: Text -> Parser Text
          prefixParserForFileType fileExtension = symbol . getCommentForFileType $ fileExtension

          multiPrefixParserForFileType :: Text -> Parser Text
          multiPrefixParserForFileType fileExtension = symbol . getMultiOpeningForFileType $ fileExtension