packages feed

lsp-2.5.0.0: src/Language/LSP/VFS.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FunctionalDependencies #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE OverloadedLabels #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE ViewPatterns #-}

{- |
Handles the "Language.LSP.Types.TextDocumentDidChange" \/
"Language.LSP.Types.TextDocumentDidOpen" \/
"Language.LSP.Types.TextDocumentDidClose" messages to keep an in-memory
`filesystem` of the current client workspace.  The server can access and edit
files in the client workspace by operating on the "VFS" in "LspFuncs".
-}
module Language.LSP.VFS (
  VFS (..),
  vfsMap,
  VirtualFile (..),
  lsp_version,
  file_version,
  file_text,
  virtualFileText,
  virtualFileVersion,
  VfsLog (..),

  -- * Managing the VFS
  emptyVFS,
  openVFS,
  changeFromClientVFS,
  changeFromServerVFS,
  persistFileVFS,
  closeVFS,

  -- * Positions and transformations
  CodePointPosition (..),
  line,
  character,
  codePointPositionToPosition,
  positionToCodePointPosition,
  CodePointRange (..),
  start,
  end,
  codePointRangeToRange,
  rangeToCodePointRange,

  -- * manipulating the file contents
  rangeLinesFromVfs,

  -- * for tests
  applyChanges,
  applyChange,
  changeChars,
) where

import Colog.Core (LogAction (..), Severity (..), WithSeverity (..), (<&))
import Control.Lens hiding (parts, (<.>))
import Control.Monad
import Control.Monad.State
import Data.Foldable (traverse_)
import Data.Hashable
import Data.Int (Int32)
import Data.List
import Data.Map.Strict qualified as Map
import Data.Ord
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.IO qualified as T
import Data.Text.Utf16.Lines as Utf16 (Position (..))
import Data.Text.Utf16.Rope.Mixed (Rope)
import Data.Text.Utf16.Rope.Mixed qualified as Rope
import Language.LSP.Protocol.Lens qualified as J
import Language.LSP.Protocol.Message qualified as J
import Language.LSP.Protocol.Types qualified as J
import Prettyprinter hiding (line)
import System.Directory
import System.FilePath
import System.IO

-- ---------------------------------------------------------------------
{-# ANN module ("hlint: ignore Eta reduce" :: String) #-}
{-# ANN module ("hlint: ignore Redundant do" :: String) #-}

-- ---------------------------------------------------------------------

data VirtualFile = VirtualFile
  { _lsp_version :: !Int32
  -- ^ The LSP version of the document
  , _file_version :: !Int
  -- ^ This number is only incremented whilst the file
  -- remains in the map.
  , _file_text :: !Rope
  -- ^ The full contents of the document
  }
  deriving (Show)

data VFS = VFS
  { _vfsMap :: !(Map.Map J.NormalizedUri VirtualFile)
  }
  deriving (Show)

data VfsLog
  = SplitInsideCodePoint Utf16.Position Rope
  | URINotFound J.NormalizedUri
  | Opening J.NormalizedUri
  | Closing J.NormalizedUri
  | PersistingFile J.NormalizedUri FilePath
  | CantRecursiveDelete J.NormalizedUri
  | DeleteNonExistent J.NormalizedUri
  deriving (Show)

instance Pretty VfsLog where
  pretty (SplitInsideCodePoint pos r) =
    "VFS: asked to make change inside code point. Position" <+> viaShow pos <+> "in" <+> viaShow r
  pretty (URINotFound uri) = "VFS: don't know about URI" <+> pretty uri
  pretty (Opening uri) = "VFS: opening" <+> pretty uri
  pretty (Closing uri) = "VFS: closing" <+> pretty uri
  pretty (PersistingFile uri fp) = "VFS: Writing virtual file for" <+> pretty uri <+> "to" <+> viaShow fp
  pretty (CantRecursiveDelete uri) =
    "VFS: can't recursively delete" <+> pretty uri <+> "because we don't track directory status"
  pretty (DeleteNonExistent uri) = "VFS: asked to delete non-existent file" <+> pretty uri

makeFieldsNoPrefix ''VirtualFile
makeFieldsNoPrefix ''VFS

---

virtualFileText :: VirtualFile -> Text
virtualFileText vf = Rope.toText (_file_text vf)

virtualFileVersion :: VirtualFile -> Int32
virtualFileVersion vf = _lsp_version vf

---

emptyVFS :: VFS
emptyVFS = VFS mempty

-- ---------------------------------------------------------------------

-- | Applies the changes from a 'J.DidOpenTextDocument' to the 'VFS'
openVFS :: (MonadState VFS m) => LogAction m (WithSeverity VfsLog) -> J.TMessage 'J.Method_TextDocumentDidOpen -> m ()
openVFS logger msg = do
  let J.TextDocumentItem (J.toNormalizedUri -> uri) _ version text = msg ^. J.params . J.textDocument
      vfile = VirtualFile version 0 (Rope.fromText text)
  logger <& Opening uri `WithSeverity` Debug
  vfsMap . at uri .= Just vfile

-- ---------------------------------------------------------------------

-- | Applies a 'DidChangeTextDocumentNotification' to the 'VFS'
changeFromClientVFS :: (MonadState VFS m) => LogAction m (WithSeverity VfsLog) -> J.TMessage 'J.Method_TextDocumentDidChange -> m ()
changeFromClientVFS logger msg = do
  let
    J.DidChangeTextDocumentParams vid changes = msg ^. J.params
    -- the client shouldn't be sending over a null version, only the server, but we just use 0 if that happens
    J.VersionedTextDocumentIdentifier (J.toNormalizedUri -> uri) version = vid
  vfs <- get
  case vfs ^. vfsMap . at uri of
    Just (VirtualFile _ file_ver contents) -> do
      contents' <- applyChanges logger contents changes
      vfsMap . at uri .= Just (VirtualFile version (file_ver + 1) contents')
    Nothing -> logger <& URINotFound uri `WithSeverity` Warning

-- ---------------------------------------------------------------------

applyCreateFile :: (MonadState VFS m) => J.CreateFile -> m ()
applyCreateFile (J.CreateFile _ann _kind (J.toNormalizedUri -> uri) options) =
  vfsMap
    %= Map.insertWith
      (\new old -> if shouldOverwrite then new else old)
      uri
      (VirtualFile 0 0 mempty)
 where
  shouldOverwrite :: Bool
  shouldOverwrite = case options of
    Nothing -> False -- default
    Just (J.CreateFileOptions Nothing Nothing) -> False -- default
    Just (J.CreateFileOptions Nothing (Just True)) -> False -- `ignoreIfExists` is True
    Just (J.CreateFileOptions Nothing (Just False)) -> True -- `ignoreIfExists` is False
    Just (J.CreateFileOptions (Just True) Nothing) -> True -- `overwrite` is True
    Just (J.CreateFileOptions (Just True) (Just True)) -> True -- `overwrite` wins over `ignoreIfExists`
    Just (J.CreateFileOptions (Just True) (Just False)) -> True -- `overwrite` is True
    Just (J.CreateFileOptions (Just False) Nothing) -> False -- `overwrite` is False
    Just (J.CreateFileOptions (Just False) (Just True)) -> False -- `overwrite` is False
    Just (J.CreateFileOptions (Just False) (Just False)) -> False -- `overwrite` wins over `ignoreIfExists`

applyRenameFile :: (MonadState VFS m) => J.RenameFile -> m ()
applyRenameFile (J.RenameFile _ann _kind (J.toNormalizedUri -> oldUri) (J.toNormalizedUri -> newUri) options) = do
  vfs <- get
  case vfs ^. vfsMap . at oldUri of
    -- nothing to rename
    Nothing -> pure ()
    Just file -> case vfs ^. vfsMap . at newUri of
      -- the target does not exist, just move over
      Nothing -> do
        vfsMap . at oldUri .= Nothing
        vfsMap . at newUri .= Just file
      Just _ -> when shouldOverwrite $ do
        vfsMap . at oldUri .= Nothing
        vfsMap . at newUri .= Just file
 where
  shouldOverwrite :: Bool
  shouldOverwrite = case options of
    Nothing -> False -- default
    Just (J.RenameFileOptions Nothing Nothing) -> False -- default
    Just (J.RenameFileOptions Nothing (Just True)) -> False -- `ignoreIfExists` is True
    Just (J.RenameFileOptions Nothing (Just False)) -> True -- `ignoreIfExists` is False
    Just (J.RenameFileOptions (Just True) Nothing) -> True -- `overwrite` is True
    Just (J.RenameFileOptions (Just True) (Just True)) -> True -- `overwrite` wins over `ignoreIfExists`
    Just (J.RenameFileOptions (Just True) (Just False)) -> True -- `overwrite` is True
    Just (J.RenameFileOptions (Just False) Nothing) -> False -- `overwrite` is False
    Just (J.RenameFileOptions (Just False) (Just True)) -> False -- `overwrite` is False
    Just (J.RenameFileOptions (Just False) (Just False)) -> False -- `overwrite` wins over `ignoreIfExists`

applyDeleteFile :: (MonadState VFS m) => LogAction m (WithSeverity VfsLog) -> J.DeleteFile -> m ()
applyDeleteFile logger (J.DeleteFile _ann _kind (J.toNormalizedUri -> uri) options) = do
  -- NOTE: we are ignoring the `recursive` option here because we don't know which file is a directory
  when (options ^? _Just . J.recursive . _Just == Just True) $
    logger <& CantRecursiveDelete uri `WithSeverity` Warning
  -- Remove and get the old value so we can check if it was missing
  old <- vfsMap . at uri <.= Nothing
  case old of
    -- It's not entirely clear what the semantics of 'ignoreIfNotExists' are, but if it
    -- doesn't exist and we're not ignoring it, let's at least log it.
    Nothing
      | options ^? _Just . J.ignoreIfNotExists . _Just /= Just True ->
          logger <& CantRecursiveDelete uri `WithSeverity` Warning
    _ -> pure ()

applyTextDocumentEdit :: (MonadState VFS m) => LogAction m (WithSeverity VfsLog) -> J.TextDocumentEdit -> m ()
applyTextDocumentEdit logger (J.TextDocumentEdit vid edits) = do
  -- all edits are supposed to be applied at once
  -- so apply from bottom up so they don't affect others
  let sortedEdits = sortOn (Down . editRange) edits
      changeEvents = map editToChangeEvent sortedEdits
      -- TODO: is this right?
      vid' = J.VersionedTextDocumentIdentifier (vid ^. J.uri) (case vid ^. J.version of J.InL v -> v; J.InR _ -> 0)
      ps = J.DidChangeTextDocumentParams vid' changeEvents
      notif = J.TNotificationMessage "" J.SMethod_TextDocumentDidChange ps
  changeFromClientVFS logger notif
 where
  editRange :: J.TextEdit J.|? J.AnnotatedTextEdit -> J.Range
  editRange (J.InR e) = e ^. J.range
  editRange (J.InL e) = e ^. J.range

  editToChangeEvent :: J.TextEdit J.|? J.AnnotatedTextEdit -> J.TextDocumentContentChangeEvent
  editToChangeEvent (J.InR e) = J.TextDocumentContentChangeEvent $ J.InL $ J.TextDocumentContentChangePartial{_range = e ^. J.range, _rangeLength = Nothing, _text = e ^. J.newText}
  editToChangeEvent (J.InL e) = J.TextDocumentContentChangeEvent $ J.InL $ J.TextDocumentContentChangePartial{_range = e ^. J.range, _rangeLength = Nothing, _text = e ^. J.newText}

applyDocumentChange :: (MonadState VFS m) => LogAction m (WithSeverity VfsLog) -> J.DocumentChange -> m ()
applyDocumentChange logger (J.InL change) = applyTextDocumentEdit logger change
applyDocumentChange _ (J.InR (J.InL change)) = applyCreateFile change
applyDocumentChange _ (J.InR (J.InR (J.InL change))) = applyRenameFile change
applyDocumentChange logger (J.InR (J.InR (J.InR change))) = applyDeleteFile logger change

-- | Applies the changes from a 'ApplyWorkspaceEditRequest' to the 'VFS'
changeFromServerVFS :: forall m. MonadState VFS m => LogAction m (WithSeverity VfsLog) -> J.TMessage 'J.Method_WorkspaceApplyEdit -> m ()
changeFromServerVFS logger msg = do
  let J.ApplyWorkspaceEditParams _label edit = msg ^. J.params
      J.WorkspaceEdit mChanges mDocChanges _anns = edit
  case mDocChanges of
    Just docChanges -> applyDocumentChanges docChanges
    Nothing -> case mChanges of
      Just cs -> applyDocumentChanges $ map J.InL $ Map.foldlWithKey' changeToTextDocumentEdit [] cs
      Nothing -> pure ()
 where
  changeToTextDocumentEdit acc uri edits =
    acc ++ [J.TextDocumentEdit (J.OptionalVersionedTextDocumentIdentifier uri (J.InL 0)) (fmap J.InL edits)]

  applyDocumentChanges :: [J.DocumentChange] -> m ()
  applyDocumentChanges = traverse_ (applyDocumentChange logger) . sortOn project

  -- for sorting [DocumentChange]
  project :: J.DocumentChange -> Maybe J.Int32
  project (J.InL textDocumentEdit) = case textDocumentEdit ^. J.textDocument . J.version of
    J.InL v -> Just v
    _ -> Nothing
  project _ = Nothing

-- ---------------------------------------------------------------------
virtualFileName :: FilePath -> J.NormalizedUri -> VirtualFile -> FilePath
virtualFileName prefix uri (VirtualFile _ file_ver _) =
  let uri_raw = J.fromNormalizedUri uri
      basename = maybe "" takeFileName (J.uriToFilePath uri_raw)
      -- Given a length and a version number, pad the version number to
      -- the given n. Does nothing if the version number string is longer
      -- than the given length.
      padLeft :: Int -> Int -> String
      padLeft n num =
        let numString = show num
         in replicate (n - length numString) '0' ++ numString
   in prefix </> basename ++ "-" ++ padLeft 5 file_ver ++ "-" ++ show (hash uri_raw) <.> takeExtensions basename

-- | Write a virtual file to a file in the given directory if it exists in the VFS.
persistFileVFS :: (MonadIO m) => LogAction m (WithSeverity VfsLog) -> FilePath -> VFS -> J.NormalizedUri -> Maybe (FilePath, m ())
persistFileVFS logger dir vfs uri =
  case vfs ^. vfsMap . at uri of
    Nothing -> Nothing
    Just vf ->
      let tfn = virtualFileName dir uri vf
          action = do
            exists <- liftIO $ doesFileExist tfn
            unless exists $ do
              let contents = Rope.toText (_file_text vf)
                  writeRaw h = do
                    -- We honour original file line endings
                    hSetNewlineMode h noNewlineTranslation
                    hSetEncoding h utf8
                    T.hPutStr h contents
              logger <& PersistingFile uri tfn `WithSeverity` Debug
              liftIO $ withFile tfn WriteMode writeRaw
       in Just (tfn, action)

-- ---------------------------------------------------------------------

closeVFS :: (MonadState VFS m) => LogAction m (WithSeverity VfsLog) -> J.TMessage 'J.Method_TextDocumentDidClose -> m ()
closeVFS logger msg = do
  let J.DidCloseTextDocumentParams (J.TextDocumentIdentifier (J.toNormalizedUri -> uri)) = msg ^. J.params
  logger <& Closing uri `WithSeverity` Debug
  vfsMap . at uri .= Nothing

-- ---------------------------------------------------------------------

{- | Apply the list of changes.
 Changes should be applied in the order that they are
 received from the client.
-}
applyChanges :: (Monad m) => LogAction m (WithSeverity VfsLog) -> Rope -> [J.TextDocumentContentChangeEvent] -> m Rope
applyChanges logger = foldM (applyChange logger)

-- ---------------------------------------------------------------------

applyChange :: (Monad m) => LogAction m (WithSeverity VfsLog) -> Rope -> J.TextDocumentContentChangeEvent -> m Rope
applyChange logger str (J.TextDocumentContentChangeEvent (J.InL e))
  | J.Range (J.Position sl sc) (J.Position fl fc) <- e ^. J.range
  , txt <- e ^. J.text =
      changeChars logger str (Utf16.Position (fromIntegral sl) (fromIntegral sc)) (Utf16.Position (fromIntegral fl) (fromIntegral fc)) txt
applyChange _ _ (J.TextDocumentContentChangeEvent (J.InR e)) =
  pure $ Rope.fromText $ e ^. J.text

-- ---------------------------------------------------------------------

{- | Given a 'Rope', start and end positions, and some new text, replace
 the given range with the new text. If the given positions lie within
 a code point then this does nothing (returns the original 'Rope') and logs.
-}
changeChars :: (Monad m) => LogAction m (WithSeverity VfsLog) -> Rope -> Utf16.Position -> Utf16.Position -> Text -> m Rope
changeChars logger str start finish new = do
  case Rope.utf16SplitAtPosition finish str of
    Nothing -> logger <& SplitInsideCodePoint finish str `WithSeverity` Warning >> pure str
    Just (before, after) -> case Rope.utf16SplitAtPosition start before of
      Nothing -> logger <& SplitInsideCodePoint start before `WithSeverity` Warning >> pure str
      Just (before', _) -> pure $ mconcat [before', Rope.fromText new, after]

-- ---------------------------------------------------------------------

{- | A position, like a 'J.Position', but where the offsets in the line are measured in
 Unicode code points instead of UTF-16 code units.
-}
data CodePointPosition = CodePointPosition
  { _line :: J.UInt
  -- ^ Line position in a document (zero-based).
  , _character :: J.UInt
  -- ^ Character offset on a line in a document in *code points* (zero-based).
  }
  deriving (Show, Read, Eq, Ord)

{- | A range, like a 'J.Range', but where the offsets in the line are measured in
 Unicode code points instead of UTF-16 code units.
-}
data CodePointRange = CodePointRange
  { _start :: CodePointPosition
  -- ^ The range's start position.
  , _end :: CodePointPosition
  -- ^ The range's end position.
  }
  deriving (Show, Read, Eq, Ord)

makeFieldsNoPrefix ''CodePointPosition
makeFieldsNoPrefix ''CodePointRange

{- Note [Converting between code points and code units]
This is inherently a somewhat expensive operation, but we take some care to minimize the cost.
In particular, we use the good asymptotics of 'Rope' to our advantage:
- We extract the single line that we are interested in in time logarithmic in the number of lines.
- We then split the line at the given position, and check how long the prefix is, which takes
linear time in the length of the (single) line.

So the overall process is logarithmic in the number of lines, and linear in the length of the specific
line. Which is okay-ish, so long as we don't have very long lines.

We are not able to use the `Rope.splitAtPosition`
Because when column index out of range or when the column indexing at the newline char.
The prefix result would wrap over the line and having the same result (nextLineNum, 0).
We would not be able to distinguish them. When the first case should return `Nothing`,
second case should return a `Just (CurrentLineNum, columnNumberConverted)`.
-}

{- | Extracts a specific line from a 'Rope.Rope'.
 Logarithmic in the number of lines.
-}
extractLine :: Rope.Rope -> Word -> Maybe Rope.Rope
extractLine rope l = do
  -- Check for the line being out of bounds
  let lastLine = Utf16.posLine $ Rope.utf16LengthAsPosition rope
  guard $ l <= lastLine
  let (_, suffix) = Rope.splitAtLine l rope
      (prefix, _) = Rope.splitAtLine 1 suffix
  pure prefix

{- | Given a virtual file, translate a 'CodePointPosition' in that file into a 'J.Position' in that file.

 Will return 'Nothing' if the requested position is out of bounds of the document.

 Logarithmic in the number of lines in the document, and linear in the length of the line containing
 the position.
-}
codePointPositionToPosition :: VirtualFile -> CodePointPosition -> Maybe J.Position
codePointPositionToPosition vFile (CodePointPosition l c) = do
  -- See Note [Converting between code points and code units]
  let text = _file_text vFile
  lineRope <- extractLine text $ fromIntegral l
  guard $ c <= fromIntegral (Rope.charLength lineRope)
  return $ J.Position l (fromIntegral $ Rope.utf16Length $ fst $ Rope.charSplitAt (fromIntegral c) lineRope)

{- | Given a virtual file, translate a 'CodePointRange' in that file into a 'J.Range' in that file.

 Will return 'Nothing' if any of the positions are out of bounds of the document.

 Logarithmic in the number of lines in the document, and linear in the length of the lines containing
 the positions.
-}
codePointRangeToRange :: VirtualFile -> CodePointRange -> Maybe J.Range
codePointRangeToRange vFile (CodePointRange b e) =
  J.Range <$> codePointPositionToPosition vFile b <*> codePointPositionToPosition vFile e

{- | Given a virtual file, translate a 'J.Position' in that file into a 'CodePointPosition' in that file.

 Will return 'Nothing' if the requested position lies inside a code point, or if it is out of bounds of the document.

 Logarithmic in the number of lines in the document, and linear in the length of the line containing
 the position.
-}
positionToCodePointPosition :: VirtualFile -> J.Position -> Maybe CodePointPosition
positionToCodePointPosition vFile (J.Position l c) = do
  -- See Note [Converting between code points and code units]
  let text = _file_text vFile
  lineRope <- extractLine text $ fromIntegral l
  guard $ c <= fromIntegral (Rope.utf16Length lineRope)
  CodePointPosition l . fromIntegral . Rope.charLength . fst <$> Rope.utf16SplitAt (fromIntegral c) lineRope

{- | Given a virtual file, translate a 'J.Range' in that file into a 'CodePointRange' in that file.

 Will return 'Nothing' if any of the positions are out of bounds of the document.

 Logarithmic in the number of lines in the document, and linear in the length of the lines containing
 the positions.
-}
rangeToCodePointRange :: VirtualFile -> J.Range -> Maybe CodePointRange
rangeToCodePointRange vFile (J.Range b e) =
  CodePointRange <$> positionToCodePointPosition vFile b <*> positionToCodePointPosition vFile e

rangeLinesFromVfs :: VirtualFile -> J.Range -> T.Text
rangeLinesFromVfs (VirtualFile _ _ ropetext) (J.Range (J.Position lf _cf) (J.Position lt _ct)) = r
 where
  (_, s1) = Rope.splitAtLine (fromIntegral lf) ropetext
  (s2, _) = Rope.splitAtLine (fromIntegral (lt - lf)) s1
  r = Rope.toText s2