packages feed

hls-cabal-plugin-2.2.0.0: src/Ide/Plugin/Cabal/Completion/Completions.hs

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

module Ide.Plugin.Cabal.Completion.Completions (contextToCompleter, getContext, getCabalPrefixInfo) where

import           Control.Lens                                  ((^.))
import           Control.Monad.IO.Class                        (MonadIO)
import           Control.Monad.Trans.Maybe
import           Data.Foldable                                 (asum)
import qualified Data.List                                     as List
import           Data.Map                                      (Map)
import qualified Data.Map                                      as Map
import qualified Data.Text                                     as T
import           Data.Text.Utf16.Rope                          (Rope)
import qualified Data.Text.Utf16.Rope                          as Rope
import           Development.IDE                               as D
import           Ide.Plugin.Cabal.Completion.Completer.Simple
import           Ide.Plugin.Cabal.Completion.Completer.Snippet
import           Ide.Plugin.Cabal.Completion.Completer.Types   (Completer)
import           Ide.Plugin.Cabal.Completion.Data
import           Ide.Plugin.Cabal.Completion.Types
import qualified Language.LSP.Protocol.Lens                    as JL
import qualified Language.LSP.VFS                              as VFS
import qualified System.FilePath                               as FP
import           System.FilePath                               (takeBaseName)

-- ----------------------------------------------------------------
-- Public API for Completions
-- ----------------------------------------------------------------

-- | Takes information about the completion context within the file
--  and finds the correct completer to be applied.
contextToCompleter :: Context -> Completer
-- if we are in the top level of the cabal file and not in a keyword context,
-- we can write any top level keywords or a stanza declaration
contextToCompleter (TopLevel, None) =
  snippetCompleter
    <> ( constantCompleter $
           Map.keys (cabalVersionKeyword <> cabalKeywords) ++ Map.keys stanzaKeywordMap
       )
-- if we are in a keyword context in the top level,
-- we look up that keyword in the top level context and can complete its possible values
contextToCompleter (TopLevel, KeyWord kw) =
  case Map.lookup kw (cabalVersionKeyword <> cabalKeywords) of
    Nothing -> errorNoopCompleter (LogUnknownKeyWordInContextError kw)
    Just l  -> l
-- if we are in a stanza and not in a keyword context,
-- we can write any of the stanza's keywords or a stanza declaration
contextToCompleter (Stanza s _, None) =
  case Map.lookup s stanzaKeywordMap of
    Nothing -> errorNoopCompleter (LogUnknownStanzaNameInContextError s)
    Just l  -> constantCompleter $ Map.keys l
-- if we are in a stanza's keyword's context we can complete possible values of that keyword
contextToCompleter (Stanza s _, KeyWord kw) =
  case Map.lookup s stanzaKeywordMap of
    Nothing -> errorNoopCompleter (LogUnknownStanzaNameInContextError s)
    Just m -> case Map.lookup kw m of
      Nothing -> errorNoopCompleter (LogUnknownKeyWordInContextError kw)
      Just l  -> l

-- | Takes prefix info about the previously written text
--  and a rope (representing a file), returns the corresponding context.
--
--  Can return Nothing if an error occurs.
--
--  TODO: first line can only have cabal-version: keyword
getContext :: (MonadIO m) => Recorder (WithPriority Log) -> CabalPrefixInfo -> Rope -> MaybeT m Context
getContext recorder prefInfo ls =
  case prevLinesM of
    Just prevLines -> do
      let lvlContext =
            if completionIndentation prefInfo == 0
              then TopLevel
              else currentLevel prevLines
      case lvlContext of
        TopLevel -> do
          kwContext <- MaybeT . pure $ getKeyWordContext prefInfo prevLines (cabalVersionKeyword <> cabalKeywords)
          pure (TopLevel, kwContext)
        Stanza s n ->
          case Map.lookup s stanzaKeywordMap of
            Nothing -> do
              pure (Stanza s n, None)
            Just m -> do
              kwContext <- MaybeT . pure $ getKeyWordContext prefInfo prevLines m
              pure (Stanza s n, kwContext)
    Nothing -> do
      logWith recorder Warning $ LogFileSplitError pos
      -- basically returns nothing
      fail "Abort computation"
  where
    pos = completionCursorPosition prefInfo
    prevLinesM = splitAtPosition pos ls

-- | Takes information about the current file's file path,
--  and the cursor position in the file; and builds a CabalPrefixInfo
--  with the prefix up to that cursor position.
--  Checks whether a suffix needs to be completed
--  and calculates the range in the document
--  where the completion action should be applied.
getCabalPrefixInfo :: FilePath -> VFS.PosPrefixInfo -> CabalPrefixInfo
getCabalPrefixInfo fp prefixInfo =
  CabalPrefixInfo
    { completionPrefix = completionPrefix',
      isStringNotation = mkIsStringNotation separator afterCursorText,
      completionCursorPosition = VFS.cursorPos prefixInfo,
      completionRange = Range completionStart completionEnd,
      completionWorkingDir = FP.takeDirectory fp,
      completionFileName = T.pack $ takeBaseName fp
    }
  where
    completionEnd = VFS.cursorPos prefixInfo
    completionStart =
      Position
        (_line completionEnd)
        (_character completionEnd - (fromIntegral $ T.length completionPrefix'))
    (beforeCursorText, afterCursorText) = T.splitAt cursorColumn $ VFS.fullLine prefixInfo
    completionPrefix' = T.takeWhileEnd (not . (`elem` stopConditionChars)) beforeCursorText
    separator =
      -- if there is an opening apostrophe before the cursor in the line somewhere,
      -- everything after that apostrophe is the completion prefix
      if odd $ T.count "\"" beforeCursorText
        then '\"'
        else ' '
    cursorColumn = fromIntegral $ VFS.cursorPos prefixInfo ^. JL.character
    stopConditionChars = separator : [',', ':']

    -- \| Takes the character occurring exactly before,
    --  and the text occurring after the item to be completed and
    --  returns whether the item is already surrounded by apostrophes.
    --
    --  Example: (@|@ indicates the cursor position)
    --
    --  @"./src|@ would call @'\"'@ @""@ and result in Just LeftSide
    --
    --  @"./src|"@ would call @'\"'@ @'\"'@ and result in Just Surrounded
    --
    mkIsStringNotation :: Char -> T.Text -> Maybe Apostrophe
    mkIsStringNotation '\"' restLine
      | Just ('\"', _) <- T.uncons restLine = Just Surrounded
      | otherwise = Just LeftSide
    mkIsStringNotation _ _ = Nothing

-- ----------------------------------------------------------------
-- Implementation Details
-- ----------------------------------------------------------------

-- | Takes prefix info about the previously written text,
--  a list of lines (representing a file) and a map of
--  keywords and returns a keyword context if the
--  previously written keyword matches one in the map.
--
--  From a cursor position, we traverse the cabal file upwards to
--  find the latest written keyword if there is any.
--  Values may be written on subsequent lines,
--  in order to allow for this we take the indentation of the current
--  word to be completed into account to find the correct keyword context.
getKeyWordContext :: CabalPrefixInfo -> [T.Text] -> Map KeyWordName a -> Maybe FieldContext
getKeyWordContext prefInfo ls keywords = do
  case lastNonEmptyLineM of
    Nothing -> Just None
    Just lastLine' -> do
      let (whiteSpaces, lastLine) = T.span (== ' ') lastLine'
      let keywordIndentation = T.length whiteSpaces
      let cursorIndentation = completionIndentation prefInfo
      -- in order to be in a keyword context the cursor needs
      -- to be indented more than the keyword
      if cursorIndentation > keywordIndentation
        then -- if the last thing written was a keyword without a value
        case List.find (`T.isPrefixOf` lastLine) (Map.keys keywords) of
          Nothing -> Just None
          Just kw -> Just $ KeyWord kw
        else Just None
  where
    lastNonEmptyLineM :: Maybe T.Text
    lastNonEmptyLineM = do
      (curLine, rest) <- List.uncons ls
      -- represents the current line while disregarding the
      -- currently written text we want to complete
      let cur = stripPartiallyWritten curLine
      List.find (not . T.null . T.stripEnd) $
        cur : rest

-- | Traverse the given lines (starting before current cursor position
--  up to the start of the file) to find the nearest stanza declaration,
--  if none is found we are in the top level context.
--
--  TODO: this could be merged with getKeyWordContext in order to increase
--  performance by reducing the number of times we have to traverse the cabal file.
currentLevel :: [T.Text] -> StanzaContext
currentLevel [] = TopLevel
currentLevel (cur : xs)
  | Just (s, n) <- stanza = Stanza s n
  | otherwise = currentLevel xs
  where
    stanza = asum $ map checkStanza (Map.keys stanzaKeywordMap)
    checkStanza :: StanzaType -> Maybe (StanzaType, Maybe StanzaName)
    checkStanza t =
      case T.stripPrefix t (T.strip cur) of
        Just n
          | T.null n -> Just (t, Nothing)
          | otherwise -> Just (t, Just $ T.strip n)
        Nothing -> Nothing

-- | Get all lines before the given cursor position in the given file
--  and reverse their order to traverse backwards starting from the given position.
splitAtPosition :: Position -> Rope -> Maybe [T.Text]
splitAtPosition pos ls = do
  split <- splitFile
  pure $ reverse $ Rope.lines $ fst split
  where
    splitFile = Rope.splitAtPosition ropePos ls
    ropePos =
      Rope.Position
        { Rope.posLine = fromIntegral $ pos ^. JL.line,
          Rope.posColumn = fromIntegral $ pos ^. JL.character
        }

-- | Takes a line of text and removes the last partially
-- written word to be completed.
stripPartiallyWritten :: T.Text -> T.Text
stripPartiallyWritten = T.dropWhileEnd (\y -> (y /= ' ') && (y /= ':'))

-- | Calculates how many spaces the currently completed item is indented.
completionIndentation :: CabalPrefixInfo -> Int
completionIndentation prefInfo = fromIntegral (pos ^. JL.character) - (T.length $ completionPrefix prefInfo)
  where
    pos = completionCursorPosition prefInfo