packages feed

nano-rope-0.1.0.0: bench/Lsp.hs

{-# LANGUAGE OverloadedStrings #-}

-- | Rope workloads modelled on @lsp@ and @haskell-language-server@.
-- These isolate document operations rather than running a full server.
--
-- * @applyChange@ of @Language.LSP.VFS@: two UTF-16 positions to byte
--   offsets, each checked for landing inside a code point, and a 'replace'.
-- * @getCompletionPrefix@ of ghcide: read the line after each keystroke.
-- * The tokenizer of the semantic tokens: where every token of the module
--   starts and ends, its text, and its columns in UTF-16.
-- * @positionToCodePointPosition@ and back: convert between GHC's code point
--   columns and the client's UTF-16 columns.
-- * @rangeLinesFromVfs@ and @takeLineRange@: read nearby lines for code actions.
--
-- The generated document is mostly ASCII with occasional non-ASCII comments.
--
-- Compare the combined 'Rope.metricsAtLineAndPosition' lookup with separate
-- line-start and position lookups, labelled "in two descents". This measures
-- the effect of using the combined API as well as the underlying rope.
module Lsp
  ( LspEnv (..)
  , mkLspEnv
  , lspBenchmarks
  ) where

import Control.DeepSeq (NFData (..))
import Data.Char (isAlpha, isAlphaNum, ord)
import qualified Data.List as L
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.NanoRope (Metrics (..), Position (..), Rope, Unit (..))
import qualified Data.Text.NanoRope as Rope
import Rand (rands)
import Test.Tasty.Bench

------------------------------------------------------------------------------
-- The document and the session

-- | A module: short lines, an accent or an emoji in a comment now and then.
moduleText :: Int -> Text
moduleText n = T.concat (zipWith line [0 :: Int ..] (L.take n (rands 5)))
  where
    line i r =
      T.concat
        [ T.replicate (r `mod` 4) "  "
        , case r `mod` 7 of
            0 -> "import qualified Data.Map.Strict as Map"
            1 -> "  where go acc (x : xs) = go (acc <> render x) xs"
            2 -> ""
            _ -> T.concat ["let value", T.pack (show i), " = compute (arg", T.pack (show (r `mod` 1000)), ") Map.empty"]
        , case r `mod` 97 of
            0 -> " -- caf\233 \20013\25991 \128512"
            1 -> " -- na\239ve r\233sum\233"
            _ -> ""
        , "\n"
        ]

-- | A client change: a UTF-16 range and its replacement text.
data Change = Change !Position !Position !Text

-- | A change, and where it leaves the cursor.
data Step = Step !Change !Position

instance NFData Change where
  rnf (Change _ _ t) = rnf t

instance NFData Step where
  rnf (Step c _) = rnf c

snippets :: [Text]
snippets =
  [ "\n  where\n    go acc (x : xs) = go (acc <> render x) xs\n    go acc [] = acc"
  , " -- TODO: na\239ve, see the r\233sum\233 \128512"
  , "\nimport qualified Data.Map.Strict as Map"
  , "\n    , fieldName :: !(Maybe Text)"
  , "\n\nhelper :: Monad m => Int -> m [Int]\nhelper n = traverse (pure . (+ 1)) [0 .. n]"
  , " <> mempty"
  , "\n  let result = fromMaybe defaultValue (Map.lookup key table)\n  pure result"
  ]

width16 :: Char -> Int
width16 c = if ord c > 0xFFFF then 2 else 1

utf16Length :: Text -> Int
utf16Length = T.foldl' (\n c -> n + width16 c) 0

-- | Generate typing at line ends, including occasional typos and backspaces.
-- Return both the changes and the resulting document to avoid replaying
-- them during environment setup.
typing :: Int -> Rope -> ([Step], Rope)
typing bursts rope0 = go bursts (rands 7) rope0
  where
    go :: Int -> [Int] -> Rope -> ([Step], Rope)
    go n (r1 : r2 : rs) rope
      | n > 0 =
          let line = r1 `mod` Rope.lineCount rope
              col = utf16Length (Rope.getLine line rope)
              steps = keys (T.unpack (snippets !! (r2 `mod` L.length snippets))) (1 :: Int) line col
              rope' = L.foldl' (\r (Step c _) -> applyChange TwoDescents r c) rope steps
              (rest, final) = go (n - 1) rs rope'
           in (steps ++ rest, final)
    go _ _ rope = ([], rope)

    keys [] _ _ _ = []
    keys (c : cs) i line col
      | i `mod` 17 == 0 =
          Step (Change (Position line col) (Position line col) "x") (Position line (col + 1))
            : Step (Change (Position line col) (Position line (col + 1)) "") (Position line col)
            : keys (c : cs) (i + 1) line col
      | c == '\n' = Step (Change (Position line col) (Position line col) "\n") (Position (line + 1) 0) : keys cs (i + 1) (line + 1) 0
      | otherwise =
          let col' = col + width16 c
           in Step (Change (Position line col) (Position line col) (T.singleton c)) (Position line col') : keys cs (i + 1) line col'

-- | Generate a batch of rename-like edits in reverse document order.
renaming :: Int -> Text -> [Change]
renaming edits text =
  [ Change (Position l 2) (Position l 6) "renamedIdentifier"
  | l <- L.reverse (L.nub (L.sort [candidates !! (r `mod` L.length candidates) | r <- L.take edits (rands 11)]))
  ]
  where
    candidates = [l | (l, line) <- zip [0 ..] (T.lines text), T.length line >= 8, T.all (< '\x80') line]

-- | A token's line, start and end code point columns, and UTF-16 start column.
data Token = Token !Int !Int !Int !Int

instance NFData Token where
  rnf !_ = ()

tokens :: Text -> [Token]
tokens text = concat (zipWith (\l -> go l 0 0) [0 ..] (T.lines text))
  where
    go l !col !col16 t =
      let (skipped, rest) = T.break (\c -> isAlpha c || c == '_') t
          (word, rest') = T.span (\c -> isAlphaNum c || c == '_' || c == '\'') rest
          from = col + T.length skipped
          from16 = col16 + utf16Length skipped
          to = from + T.length word
       in if T.null word
            then []
            else Token l from to from16 : go l to (from16 + utf16Length word) rest'

------------------------------------------------------------------------------
-- Language.LSP.VFS

-- | Combined or separate lookups for a position and its line start.
data Asking
  = -- | 'Rope.metricsAtLineAndPosition'.
    OneDescent
  | -- | 'Rope.metricsAtPosition' and 'Rope.metricsAt', each on its own.
    TwoDescents

lineAndPosition :: Asking -> Unit -> Position -> Rope -> (Metrics, Metrics)
lineAndPosition OneDescent u pos rope = Rope.metricsAtLineAndPosition u pos rope
lineAndPosition TwoDescents u pos rope = (Rope.metricsAt Lines (posLine pos) rope, Rope.metricsAtPosition u pos rope)
{-# INLINE lineAndPosition #-}

-- | The byte offset of a position in UTF-16 code units, or 'Nothing' if it
-- lies within a code point.
utf16PositionToBytes :: Asking -> Position -> Rope -> Maybe Int
utf16PositionToBytes asking pos@(Position l c) str
  | reached line loc == c = Just (bytes loc)
  -- Short of the column: clamped to the end of the line, or rounded down to
  -- the start of a surrogate pair, the end of which is then one further.
  | uncurry reached (lineAndPosition asking Utf16 (Position l (c + 1)) str) == c + 1 = Nothing
  | otherwise = Just (bytes loc)
  where
    (line, loc) = lineAndPosition asking Utf16 pos str
    reached from m = utf16Units m - utf16Units from
{-# INLINE utf16PositionToBytes #-}

applyChange :: Asking -> Rope -> Change -> Rope
applyChange asking str (Change start finish new) = case asking of
  -- An insertion starts where it finishes, and is asked for once.
  OneDescent | start == finish -> case utf16PositionToBytes asking start str of
    Nothing -> str
    Just i -> Rope.replace Bytes i i new str
  _ -> case utf16PositionToBytes asking finish str of
    Nothing -> str
    Just j -> case utf16PositionToBytes asking start str of
      Nothing -> str
      Just i -> Rope.replace Bytes (min i j) j new str
{-# INLINE applyChange #-}

lineBounds :: Rope -> Int -> Maybe (Metrics, Metrics)
lineBounds rope l
  | l < Rope.lineCount rope = Just (Rope.metricsAt Lines l rope, Rope.metricsAt Lines (l + 1) rope)
  | otherwise = Nothing

-- | Convert column units, returning 'Nothing' for an invalid column or one
-- inside a code point. Content positions use the combined lookup; positions
-- at line endings need an additional bounds check.
convertPosition :: Unit -> Unit -> Asking -> Rope -> Position -> Maybe Position
convertPosition from to OneDescent text pos@(Position l c)
  | (line, loc) <- Rope.metricsAtLineAndPosition from pos text
  , newlines line == l
  , Rope.count from loc - Rope.count from line == c =
      Just (Position l (Rope.count to loc - Rope.count to line))
convertPosition from to _ text (Position l c) = do
  (lineStart, lineEnd) <- lineBounds text l
  let target = Rope.count from lineStart + c
      loc = Rope.metricsAt from target text
  if target <= Rope.count from lineEnd && Rope.count from loc == target
    then Just (Position l (Rope.count to loc - Rope.count to lineStart))
    else Nothing
{-# INLINE convertPosition #-}

rangeLines :: Rope -> Int -> Int -> Text
rangeLines rope lf lt = Rope.sliceText Lines lf lt rope

------------------------------------------------------------------------------
-- ghcide and the plugins

takeLineRange :: Int -> Int -> Rope -> [Text]
takeLineRange from to rope
  | to < from = []
  | otherwise = Rope.lines (Rope.slice Lines from (to + 1) rope)

-- | Read a line, excluding an empty final line after a trailing terminator.
lineAt :: Asking -> Int -> Rope -> Maybe Text
lineAt OneDescent line rope
  | line < lastLine || line == lastLine && not (T.null text) = Just text
  | otherwise = Nothing
  where
    lastLine = Rope.lineCount rope - 1
    text = Rope.getLine line rope
lineAt TwoDescents line rope
  | Rope.convert Lines Bytes line rope < Rope.length Bytes rope = Just (Rope.getLine line rope)
  | otherwise = Nothing
{-# INLINE lineAt #-}

-- | Read the identifier prefix used for completion after a keystroke.
completionPrefix :: Asking -> Rope -> Position -> Int
completionPrefix asking rope (Position l c) = case lineAt asking l rope of
  Nothing -> 0
  Just curLine -> T.length (T.takeWhileEnd (\x -> isAlphaNum x || x == '.' || x == '_' || x == '\'') (T.take c curLine))

-- | Locate a valid code point position and its UTF-16 column for token lookup.
locate :: Asking -> Position -> Rope -> Maybe (Metrics, Int)
locate asking pos@(Position l c) rpe =
  let (lineStart, at) = lineAndPosition asking Chars pos rpe
   in if newlines lineStart == l && chars at - chars lineStart == c
        then Just (at, utf16Units at - utf16Units lineStart)
        else Nothing
{-# INLINE locate #-}

focusToken :: Asking -> Rope -> Token -> Int
focusToken asking rope (Token l from to _) =
  case (locate asking (Position l from) rope, locate asking (Position l to) rope) of
    (Just (tokenStart, ncs), Just (tokenEnd, nce)) ->
      let token = Rope.sliceText Bytes (bytes tokenStart) (bytes tokenEnd) rope
       in ncs + nce + T.length token
    _ -> -1

------------------------------------------------------------------------------
-- Workloads

-- | Replay changes and run the supplied observer after each one.
replay :: Asking -> (Rope -> Position -> Int) -> Rope -> [Step] -> Int
replay asking observe = go 0
  where
    go !acc !rope [] = acc + Rope.length Bytes rope
    go !acc !rope (Step c cursor : steps) =
      let rope' = applyChange asking rope c
       in go (acc + observe rope' cursor) rope' steps
{-# INLINE replay #-}

observeNothing :: Rope -> Position -> Int
observeNothing rope _ = rope `seq` 1

semanticTokens :: Asking -> Rope -> [Token] -> Int
semanticTokens asking rope = L.foldl' (\n t -> n + focusToken asking rope t) 0
{-# INLINE semanticTokens #-}

convertPositions :: Asking -> Rope -> [Position] -> Int
convertPositions asking rope = L.foldl' step 0
  where
    step n p = case convertPosition Utf16 Chars asking rope p of
      Just cp | Just (Position l c) <- convertPosition Chars Utf16 asking rope cp -> n + l + c
      _ -> n - 1
{-# INLINE convertPositions #-}

readLines :: Rope -> [Position] -> Int
readLines rope = L.foldl' step 0
  where
    step n (Position l _) =
      n + T.length (rangeLines rope l (l + 3)) + sum (map T.length (takeLineRange l (l + 2) rope))

rename :: Asking -> Rope -> [Change] -> Int
rename asking rope changes = T.length (Rope.toText (L.foldl' (applyChange asking) rope changes))
{-# INLINE rename #-}

data LspEnv = LspEnv
  { lspOpened :: !Rope
  , lspEdited :: !Rope
  -- ^ Document after the generated typing session.
  , lspTyping :: ![Step]
  , lspRenaming :: ![Change]
  , lspTokens :: ![Token]
  -- ^ Tokens from the edited document.
  , lspPositions :: ![Position]
  -- ^ UTF-16 start positions of every fourth token.
  }

instance NFData LspEnv where
  rnf e = rnf (lspTyping e) `seq` rnf (lspRenaming e) `seq` rnf (lspTokens e) `seq` rnf (lspPositions e)

mkLspEnv :: Int -> LspEnv
mkLspEnv nLines =
  LspEnv
    { lspOpened = opened
    , lspEdited = edited
    , lspTyping = steps
    , lspRenaming = renaming 200 text
    , lspTokens = toks
    , lspPositions = every 4 [Position l c | Token l _ _ c <- toks]
    }
  where
    text = moduleText nLines
    opened = Rope.fromText text
    (steps, edited) = typing 100 opened
    toks = tokens (Rope.toText edited)
    every n xs = case xs of
      [] -> []
      x : _ -> x : every n (L.drop n xs)

lspBenchmarks :: LspEnv -> [Benchmark]
lspBenchmarks e =
  asked OneDescent
    ++ [ bench "reading lines" $ whnf (readLines (lspEdited e)) (lspPositions e)
       , bgroup "in two descents" (asked TwoDescents)
       ]
  where
    -- Inlined, so that each workload is compiled for the one way of asking.
    asked asking =
      [ bench "typing, the edits alone" $ whnf (replay asking observeNothing (lspOpened e)) (lspTyping e)
      , bench "typing, completion prefix after each key" $ whnf (replay asking (completionPrefix asking) (lspOpened e)) (lspTyping e)
      , bench "rename, 200 edits at once" $ whnf (rename asking (lspOpened e)) (lspRenaming e)
      , bench "semantic tokens" $ whnf (semanticTokens asking (lspEdited e)) (lspTokens e)
      , bench "position conversions" $ whnf (convertPositions asking (lspEdited e)) (lspPositions e)
      ]
    {-# INLINE asked #-}