Skip to content

Add RangeMap for unified "in-range" filtering #3343

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Nov 26, 2022
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions hls-plugin-api/bench/Main.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
-- A benchmark comparing the performance characteristics of list-based
-- vs RangeMap-based "in-range filtering" approaches
module Main (main) where

import Control.DeepSeq (force)
import Control.Exception (evaluate)
import Control.Monad (replicateM)
import qualified Criterion
import qualified Criterion.Main
import Data.Random (RVar)
import qualified Data.Random as Fu
import qualified Ide.Plugin.RangeMap as RangeMap
import Language.LSP.Types (Position (..), Range (..), UInt,
isSubrangeOf)
import qualified System.Random.Stateful as Random


genRangeList :: Int -> RVar [Range]
genRangeList n = replicateM n genRange

genRange :: RVar Range
genRange = do
x1 <- genPosition
delta <- genRangeLength
let x2 = x1 { _character = _character x1 + delta }
pure $ Range x1 x2
where
genRangeLength :: RVar UInt
genRangeLength = fromInteger <$> Fu.uniform 5 50

genPosition :: RVar Position
genPosition = Position
<$> (fromInteger <$> Fu.uniform 0 10000)
<*> (fromInteger <$> Fu.uniform 0 150)

filterRangeList :: Range -> [Range] -> [Range]
filterRangeList r = filter (isSubrangeOf r)

main :: IO ()
main = do
rangeLists@[rangeList100, rangeList1000, rangeList10000]
<- traverse (Fu.sampleFrom Random.globalStdGen . genRangeList) [100, 1000, 10000]
[rangeMap100, rangeMap1000, rangeMap10000] <- evaluate $ force $ map (RangeMap.fromList id) rangeLists
targetRange <- Fu.sampleFrom Random.globalStdGen genRange
Criterion.Main.defaultMain
[ Criterion.bgroup "List"
[ Criterion.bench "Size 100" $ Criterion.nf (filterRangeList targetRange) rangeList100
, Criterion.bench "Size 1000" $ Criterion.nf (filterRangeList targetRange) rangeList1000
, Criterion.bench "Size 10000" $ Criterion.nf (filterRangeList targetRange) rangeList10000
]
, Criterion.bgroup "RangeMap"
[ Criterion.bench "Size 100" $ Criterion.nf (RangeMap.filterByRange targetRange) rangeMap100
, Criterion.bench "Size 1000" $ Criterion.nf (RangeMap.filterByRange targetRange) rangeMap1000
, Criterion.bench "Size 10000" $ Criterion.nf (RangeMap.filterByRange targetRange) rangeMap10000
]
]
29 changes: 29 additions & 0 deletions hls-plugin-api/hls-plugin-api.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ flag pedantic
default: False
manual: True

flag use-fingertree
description: Use fingertree implementation of RangeMap
default: True
manual: False

source-repository head
type: git
location: https://github.com/haskell/haskell-language-server
Expand All @@ -29,6 +34,7 @@ library
Ide.Plugin.Config
Ide.Plugin.ConfigUtils
Ide.Plugin.Properties
Ide.Plugin.RangeMap
Ide.PluginUtils
Ide.Types

Expand Down Expand Up @@ -73,6 +79,10 @@ library
if impl(ghc >= 9)
ghc-options: -Wunused-packages

if flag(use-fingertree)
cpp-options: -DUSE_FINGERTREE
build-depends: hw-fingertree

default-language: Haskell2010
default-extensions:
DataKinds
Expand All @@ -92,5 +102,24 @@ test-suite tests
, tasty
, tasty-hunit
, tasty-rerun
, tasty-quickcheck
, text
, lsp-types
, containers

benchmark rangemap-benchmark
if !flag(use-fingertree)
buildable: False
type: exitcode-stdio-1.0
default-language: Haskell2010
hs-source-dirs: bench
main-is: Main.hs
ghc-options: -threaded -Wall
build-depends:
base
, hls-plugin-api
, lsp-types
, criterion
, random
, random-fu
, deepseq
72 changes: 72 additions & 0 deletions hls-plugin-api/src/Ide/Plugin/RangeMap.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
{-# LANGUAGE CPP #-}
{-# LANGUAGE DeriveFoldable #-}
{-# LANGUAGE DeriveFunctor #-}
{-# LANGUAGE DeriveTraversable #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}

-- | A map that allows fast \"in-range\" filtering. 'RangeMap' is meant
-- to be constructed once and cached as part of a Shake rule. If
-- not, the map will be rebuilt upon each invocation, yielding slower
-- results compared to the list-based approach!
--
-- Note that 'RangeMap' falls back to the list-based approach if
-- `use-fingertree` flag of `hls-plugin-api` is set to false.
module Ide.Plugin.RangeMap
( RangeMap(..),
fromList,
fromList',
filterByRange,
) where

import Data.Bifunctor (first)
import Data.Foldable (foldl')
import Development.IDE.Graph.Classes (NFData)
import Language.LSP.Types (Position,
Range (Range),
isSubrangeOf)
#ifdef USE_FINGERTREE
import qualified HaskellWorks.Data.IntervalMap.FingerTree as IM
#endif

-- | A map from code ranges to values.
#ifdef USE_FINGERTREE
newtype RangeMap a = RangeMap
{ unRangeMap :: IM.IntervalMap Position a
-- ^ 'IM.Interval' of 'Position' corresponds to a 'Range'
}
deriving newtype (NFData, Semigroup, Monoid)
deriving stock (Functor, Foldable, Traversable)
#else
newtype RangeMap a = RangeMap
{ unRangeMap :: [(Range, a)] }
deriving newtype (NFData, Semigroup, Monoid)
deriving stock (Functor, Foldable, Traversable)
#endif

-- | Construct a 'RangeMap' from a 'Range' accessor and a list of values.
fromList :: (a -> Range) -> [a] -> RangeMap a
fromList extractRange = fromList' . map (\x -> (extractRange x, x))

fromList' :: [(Range, a)] -> RangeMap a
#ifdef USE_FINGERTREE
fromList' = RangeMap . toIntervalMap . map (first rangeToInterval)
where
toIntervalMap :: Ord v => [(IM.Interval v, a)] -> IM.IntervalMap v a
toIntervalMap = foldl' (\m (i, v) -> IM.insert i v m) IM.empty
#else
fromList' = RangeMap
#endif

-- | Filter a 'RangeMap' by a given 'Range'.
filterByRange :: Range -> RangeMap a -> [a]
#ifdef USE_FINGERTREE
filterByRange range = map snd . IM.dominators (rangeToInterval range) . unRangeMap
#else
filterByRange range = map snd . filter (isSubrangeOf range . fst) . unRangeMap
#endif

#ifdef USE_FINGERTREE
rangeToInterval :: Range -> IM.Interval Position
rangeToInterval (Range s e) = IM.Interval s e
#endif
59 changes: 55 additions & 4 deletions hls-plugin-api/test/Ide/PluginUtilsTest.hs
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}

module Ide.PluginUtilsTest
( tests
) where

import Data.Char (isPrint)
import qualified Data.Text as T
import Ide.PluginUtils (positionInRange, unescape)
import Language.LSP.Types (Position (Position), Range (Range))
import Data.Char (isPrint)
import qualified Data.Set as Set
import qualified Data.Text as T
import qualified Ide.Plugin.RangeMap as RangeMap
import Ide.PluginUtils (positionInRange, unescape)
import Language.LSP.Types (Position (..), Range (Range), UInt,
isSubrangeOf)
import Test.Tasty
import Test.Tasty.HUnit
import Test.Tasty.QuickCheck

tests :: TestTree
tests = testGroup "PluginUtils"
[ unescapeTest
, localOption (QuickCheckMaxSize 10000) $
testProperty "RangeMap-List filtering identical" $
prop_rangemapListEq @Int
]

unescapeTest :: TestTree
Expand All @@ -33,3 +41,46 @@ unescapeTest = testGroup "unescape"
, testCase "control characters should not be escaped" $
unescape "\"\\n\\t\"" @?= "\"\\n\\t\""
]

genRange :: Gen Range
genRange = oneof [ genRangeInline, genRangeMultiline ]

genRangeInline :: Gen Range
genRangeInline = do
x1 <- genPosition
delta <- genRangeLength
let x2 = x1 { _character = _character x1 + delta }
pure $ Range x1 x2
where
genRangeLength :: Gen UInt
genRangeLength = fromInteger <$> chooseInteger (5, 50)

genRangeMultiline :: Gen Range
genRangeMultiline = do
x1 <- genPosition
let heightDelta = 1
secondX <- genSecond
let x2 = x1 { _line = _line x1 + heightDelta
, _character = secondX
}
pure $ Range x1 x2
where
genSecond :: Gen UInt
genSecond = fromInteger <$> chooseInteger (0, 10)

genPosition :: Gen Position
genPosition = Position
<$> (fromInteger <$> chooseInteger (0, 1000))
<*> (fromInteger <$> chooseInteger (0, 150))

instance Arbitrary Range where
arbitrary = genRange

prop_rangemapListEq :: (Show a, Eq a, Ord a) => Range -> [(Range, a)] -> Property
prop_rangemapListEq r xs =
let filteredList = (map snd . filter (isSubrangeOf r . fst)) xs
filteredRangeMap = RangeMap.filterByRange r (RangeMap.fromList' xs)
in classify (null filteredList) "no matches" $
cover 5 (length filteredList == 1) "1 match" $
cover 2 (length filteredList > 1) ">1 matches" $
Set.fromList filteredList === Set.fromList filteredRangeMap
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import Ide.Plugin.Conversion (AlternateFormat,
ExtensionNeeded (NeedsExtension, NoExtension),
alternateFormat)
import Ide.Plugin.Literals
import Ide.Plugin.RangeMap (RangeMap)
import qualified Ide.Plugin.RangeMap as RangeMap
import Ide.PluginUtils (getNormalizedFilePath,
handleMaybeM, pluginResponse)
import Ide.Types
Expand Down Expand Up @@ -52,7 +54,7 @@ instance NFData CollectLiterals
type instance RuleResult CollectLiterals = CollectLiteralsResult

data CollectLiteralsResult = CLR
{ literals :: [Literal]
{ literals :: RangeMap Literal
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice

, enabledExtensions :: [GhcExtension]
} deriving (Generic)

Expand All @@ -73,25 +75,22 @@ collectLiteralsRule recorder = define (cmapWithPrio LogShake recorder) $ \Collec
let exts = map GhcExtension . getExtensions <$> pm
-- collect all the literals for a file
lits = collectLiterals . pm_parsed_source <$> pm
pure ([], CLR <$> lits <*> exts)
litMap = RangeMap.fromList (realSrcSpanToRange . getSrcSpan) <$> lits
pure ([], CLR <$> litMap <*> exts)

codeActionHandler :: PluginMethodHandler IdeState 'TextDocumentCodeAction
codeActionHandler state pId (CodeActionParams _ _ docId currRange _) = pluginResponse $ do
nfp <- getNormalizedFilePath (docId ^. L.uri)
CLR{..} <- requestLiterals pId state nfp
pragma <- getFirstPragma pId state nfp
-- remove any invalid literals (see validTarget comment)
let litsInRange = filter inCurrentRange literals
let litsInRange = RangeMap.filterByRange currRange literals
-- generate alternateFormats and zip with the literal that generated the alternates
literalPairs = map (\lit -> (lit, alternateFormat lit)) litsInRange
-- make a code action for every literal and its' alternates (then flatten the result)
actions = concatMap (\(lit, alts) -> map (mkCodeAction nfp lit enabledExtensions pragma) alts) literalPairs
pure $ List actions
where
inCurrentRange :: Literal -> Bool
inCurrentRange lit = let srcSpan = getSrcSpan lit
in currRange `contains` srcSpan

mkCodeAction :: NormalizedFilePath -> Literal -> [GhcExtension] -> NextPragmaInfo -> AlternateFormat -> Command |? CodeAction
mkCodeAction nfp lit enabled npi af@(alt, ext) = InR CodeAction {
_title = mkCodeActionTitle lit af enabled
Expand Down Expand Up @@ -127,13 +126,6 @@ mkCodeActionTitle lit (alt, ext) ghcExts
needsExtension :: Extension -> [GhcExtension] -> Bool
needsExtension ext ghcExts = ext `notElem` map unExt ghcExts

-- from HaddockComments.hs
contains :: Range -> RealSrcSpan -> Bool
contains Range {_start, _end} x = isInsideRealSrcSpan _start x || isInsideRealSrcSpan _end x

isInsideRealSrcSpan :: Position -> RealSrcSpan -> Bool
p `isInsideRealSrcSpan` r = let (Range sp ep) = realSrcSpanToRange r in sp <= p && p <= ep

requestLiterals :: MonadIO m => PluginId -> IdeState -> NormalizedFilePath -> ExceptT String m CollectLiteralsResult
requestLiterals (PluginId pId) state = handleMaybeM "Could not Collect Literals"
. liftIO
Expand Down
2 changes: 1 addition & 1 deletion plugins/hls-alternate-number-format-plugin/test/Main.hs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ test :: TestTree
test = testGroup "alternateNumberFormat" [
codeActionHex "TIntDtoH" 3 13
, codeActionOctal "TIntDtoO" 3 13
, codeActionBinary "TIntDtoB" 4 12
, codeActionBinary "TIntDtoB" 4 13
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what happened here?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't cause any worries, it's just the column number to run the code action. I think the column number is arbitrarily in the middle of a number. Looks like it just matches the other test cases.

, codeActionNumDecimal "TIntDtoND" 5 13
, codeActionFracExp "TFracDtoE" 3 13
, codeActionFloatHex "TFracDtoHF" 4 13
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ library
, transformers
, ghc-boot-th
, unordered-containers
, hw-fingertree
hs-source-dirs: src
default-language: Haskell2010

Expand Down
Loading