Skip to content

Commit ad60fbb

Browse files
committed
Refactor context search to use readFields
Instead of custom parsing of the cabal file, we use `readFields` to parse the cabal file, as accurately as cabal supports. This allows us to additionally benefit from future improvements to the cabal lexer. Then, we traverse the fields and find the most likely location of the cursor in the cabal file. Based on these results, we can then establish the context accurately.
1 parent 838a51f commit ad60fbb

File tree

13 files changed

+665
-249
lines changed

13 files changed

+665
-249
lines changed

Diff for: ghcide/src/Development/IDE/Plugin/Completions/Logic.hs

+5-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ module Development.IDE.Plugin.Completions.Logic (
1111
, getCompletions
1212
, fromIdentInfo
1313
, getCompletionPrefix
14+
, getCompletionPrefixFromRope
1415
) where
1516

1617
import Control.Applicative
@@ -897,7 +898,10 @@ mergeListsBy cmp all_lists = merge_lists all_lists
897898

898899
-- |From the given cursor position, gets the prefix module or record for autocompletion
899900
getCompletionPrefix :: Position -> VFS.VirtualFile -> PosPrefixInfo
900-
getCompletionPrefix pos@(Position l c) (VFS.VirtualFile _ _ ropetext) =
901+
getCompletionPrefix pos (VFS.VirtualFile _ _ ropetext) = getCompletionPrefixFromRope pos ropetext
902+
903+
getCompletionPrefixFromRope :: Position -> Rope.Rope -> PosPrefixInfo
904+
getCompletionPrefixFromRope pos@(Position l c) ropetext =
901905
fromMaybe (PosPrefixInfo "" "" "" pos) $ do -- Maybe monad
902906
let headMaybe = listToMaybe
903907
lastMaybe = headMaybe . reverse

Diff for: haskell-language-server.cabal

+1
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ library hls-cabal-plugin
241241
Ide.Plugin.Cabal.Completion.Data
242242
Ide.Plugin.Cabal.Completion.Types
243243
Ide.Plugin.Cabal.LicenseSuggest
244+
Ide.Plugin.Cabal.Orphans
244245
Ide.Plugin.Cabal.Parse
245246

246247

Diff for: hls-test-utils/hls-test-utils.cabal

+2
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ library
4949
, lsp
5050
, lsp-test ^>=0.17
5151
, lsp-types ^>=2.2
52+
, neat-interpolation
5253
, safe-exceptions
5354
, tasty
5455
, tasty-expected-failure
@@ -57,6 +58,7 @@ library
5758
, tasty-rerun
5859
, temporary
5960
, text
61+
, text-rope
6062

6163
ghc-options:
6264
-Wall

Diff for: hls-test-utils/src/Test/Hls.hs

+54-2
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ module Test.Hls
3434
runSessionWithServer,
3535
runSessionWithServerInTmpDir,
3636
runSessionWithTestConfig,
37+
-- * Running parameterised tests for a set of test configurations
38+
parameterisedCursorTest,
3739
-- * Helpful re-exports
3840
PluginDescriptor,
3941
IdeState,
@@ -87,7 +89,6 @@ import Development.IDE (IdeState,
8789
LoggingColumn (ThreadIdColumn),
8890
defaultLayoutOptions,
8991
layoutPretty, renderStrict)
90-
import qualified Development.IDE.LSP.Notifications as Notifications
9192
import Development.IDE.Main hiding (Log)
9293
import qualified Development.IDE.Main as IDEMain
9394
import Development.IDE.Plugin.Test (TestRequest (GetBuildKeysBuilt, WaitForIdeRule, WaitForShakeQueue),
@@ -105,7 +106,6 @@ import Ide.Logger (Pretty (pretty),
105106
makeDefaultStderrRecorder,
106107
(<+>))
107108
import qualified Ide.Logger as Logger
108-
import Ide.Plugin.Properties ((&))
109109
import Ide.PluginUtils (idePluginsToPluginDesc,
110110
pluginDescToIdePlugins)
111111
import Ide.Types
@@ -136,6 +136,7 @@ import Test.Tasty.ExpectedFailure
136136
import Test.Tasty.Golden
137137
import Test.Tasty.HUnit
138138
import Test.Tasty.Ingredients.Rerun
139+
import Development.IDE.Plugin.Completions.Types (PosPrefixInfo)
139140

140141
data Log
141142
= LogIDEMain IDEMain.Log
@@ -328,6 +329,56 @@ goldenWithDocInTmpDir languageKind config plugin title tree path desc ext act =
328329
act doc
329330
documentContents doc
330331

332+
-- | A parameterised test is similar to a normal test case but allows to run
333+
-- the same test case multiple times with different inputs.
334+
-- A 'parameterisedCursorTest' allows to define a test case based on an input file
335+
-- that specifies one or many cursor positions via the identification value '^'.
336+
--
337+
-- For example:
338+
--
339+
-- @
340+
-- parameterisedCursorTest "Cursor Test" [trimming|
341+
-- foo = 2
342+
-- ^
343+
-- bar = 3
344+
-- baz = foo + bar
345+
-- ^
346+
-- |]
347+
-- ["foo", "baz"]
348+
-- (\input cursor -> findFunctionNameUnderCursor input cursor)
349+
-- @
350+
--
351+
-- Assuming a fitting implementation for 'findFunctionNameUnderCursor'.
352+
--
353+
-- This test definition will run the test case 'findFunctionNameUnderCursor' for
354+
-- each cursor position, each in its own isolated 'testCase'.
355+
-- Cursor positions are identified via the character '^', which points to the
356+
-- above line as the actual cursor position.
357+
-- Lines containing '^' characters, are removed from the final text, that is
358+
-- passed to the testing function.
359+
--
360+
-- TODO: Many Haskell and Cabal source may contain '^' characters for good reasons.
361+
-- We likely need a way to change the character for certain test cases in the future.
362+
--
363+
-- The quasi quoter 'trimming' is very helpful to define such tests, as it additionally
364+
-- allows to interpolate haskell values and functions. We reexport this quasi quoter
365+
-- for easier usage.
366+
parameterisedCursorTest :: (Show a, Eq a) => String -> T.Text -> [a] -> (T.Text -> PosPrefixInfo -> IO a) -> TestTree
367+
parameterisedCursorTest title content expectations act
368+
| lenPrefs /= lenExpected = error $ "parameterisedCursorTest: Expected " <> show lenExpected <> " cursors but found: " <> show lenPrefs
369+
| otherwise = testGroup title $
370+
map singleTest testCaseSpec
371+
where
372+
lenPrefs = length prefInfos
373+
lenExpected = length expectations
374+
(cleanText, prefInfos) = extractCursorPositions content
375+
376+
testCaseSpec = zip [1 ::Int ..] (zip expectations prefInfos)
377+
378+
singleTest (n, (expected, info)) = testCase (title <> " " <> show n) $ do
379+
actual <- act cleanText info
380+
assertEqual (mkParameterisedLabel info) expected actual
381+
331382
-- ------------------------------------------------------------
332383
-- Helper function for initialising plugins under test
333384
-- ------------------------------------------------------------
@@ -429,6 +480,7 @@ initializeTestRecorder envVars = do
429480
-- ------------------------------------------------------------
430481
-- Run an HLS server testing a specific plugin
431482
-- ------------------------------------------------------------
483+
432484
runSessionWithServerInTmpDir :: Pretty b => Config -> PluginTestDescriptor b -> VirtualFileTree -> Session a -> IO a
433485
runSessionWithServerInTmpDir config plugin tree act =
434486
runSessionWithTestConfig def

Diff for: hls-test-utils/src/Test/Hls/Util.hs

+145-18
Original file line numberDiff line numberDiff line change
@@ -42,37 +42,48 @@ module Test.Hls.Util
4242
, withCurrentDirectoryInTmp
4343
, withCurrentDirectoryInTmp'
4444
, withCanonicalTempDir
45+
-- * Extract positions from input file.
46+
, extractCursorPositions
47+
, mkParameterisedLabel
48+
, trimming
4549
)
4650
where
4751

48-
import Control.Applicative.Combinators (skipManyTill, (<|>))
49-
import Control.Exception (catch, throwIO)
50-
import Control.Lens (_Just, (&), (.~), (?~), (^.))
52+
import Control.Applicative.Combinators (skipManyTill, (<|>))
53+
import Control.Exception (catch, throwIO)
54+
import Control.Lens (_Just, (&), (.~),
55+
(?~), (^.))
5156
import Control.Monad
5257
import Control.Monad.IO.Class
53-
import qualified Data.Aeson as A
54-
import Data.Bool (bool)
58+
import qualified Data.Aeson as A
59+
import Data.Bool (bool)
5560
import Data.Default
56-
import Data.List.Extra (find)
61+
import Data.List.Extra (find)
5762
import Data.Proxy
58-
import qualified Data.Set as Set
59-
import qualified Data.Text as T
60-
import Development.IDE (GhcVersion (..), ghcVersion)
61-
import qualified Language.LSP.Protocol.Lens as L
63+
import qualified Data.Text as T
64+
import Development.IDE (GhcVersion (..),
65+
ghcVersion)
66+
import qualified Language.LSP.Protocol.Lens as L
6267
import Language.LSP.Protocol.Message
6368
import Language.LSP.Protocol.Types
64-
import qualified Language.LSP.Test as Test
69+
import qualified Language.LSP.Test as Test
6570
import System.Directory
6671
import System.FilePath
67-
import System.Info.Extra (isMac, isWindows)
72+
import System.Info.Extra (isMac, isWindows)
6873
import qualified System.IO.Extra
6974
import System.IO.Temp
70-
import System.Time.Extra (Seconds, sleep)
71-
import Test.Tasty (TestTree)
72-
import Test.Tasty.ExpectedFailure (expectFailBecause,
73-
ignoreTestBecause)
74-
import Test.Tasty.HUnit (Assertion, assertFailure,
75-
(@?=))
75+
import System.Time.Extra (Seconds, sleep)
76+
import Test.Tasty (TestTree)
77+
import Test.Tasty.ExpectedFailure (expectFailBecause,
78+
ignoreTestBecause)
79+
import Test.Tasty.HUnit (assertFailure)
80+
81+
import qualified Data.List as List
82+
import qualified Data.Text.Internal.Search as T
83+
import qualified Data.Text.Utf16.Rope.Mixed as Rope
84+
import Development.IDE.Plugin.Completions.Logic (getCompletionPrefixFromRope)
85+
import Development.IDE.Plugin.Completions.Types (PosPrefixInfo (..))
86+
import NeatInterpolation (trimming)
7687

7788
noLiteralCaps :: ClientCapabilities
7889
noLiteralCaps = def & L.textDocument ?~ textDocumentCaps
@@ -327,3 +338,119 @@ withCanonicalTempDir :: (FilePath -> IO a) -> IO a
327338
withCanonicalTempDir f = System.IO.Extra.withTempDir $ \dir -> do
328339
dir' <- canonicalizePath dir
329340
f dir'
341+
342+
-- ----------------------------------------------------------------------------
343+
-- Extract Position data from the source file itself.
344+
-- ----------------------------------------------------------------------------
345+
346+
-- | Pretty labelling for tests that use the parameterised test helpers.
347+
mkParameterisedLabel :: PosPrefixInfo -> String
348+
mkParameterisedLabel posPrefixInfo = unlines
349+
[ "Full Line: \"" <> T.unpack (fullLine posPrefixInfo) <> "\""
350+
, "Cursor Column: \"" <> replicate (fromIntegral $ cursorPos posPrefixInfo ^. L.character) ' ' ++ "^" <> "\""
351+
, "Prefix Text: \"" <> T.unpack (prefixText posPrefixInfo) <> "\""
352+
]
353+
354+
-- | Given a in-memory representation of a file, where a user can specify the
355+
-- current cursor position using a '^' in the next line.
356+
--
357+
-- This function allows to generate multiple tests for a single input file, without
358+
-- the hassle of calculating by hand where there cursor is supposed to be.
359+
--
360+
-- Example (line number has been added for readability):
361+
--
362+
-- @
363+
-- 0: foo = 2
364+
-- 1: ^
365+
-- 2: bar =
366+
-- 3: ^
367+
-- @
368+
--
369+
-- This example input file contains two cursor positions (y, x), at
370+
--
371+
-- * (1, 1), and
372+
-- * (3, 5).
373+
--
374+
-- 'extractCursorPositions' will search for '^' characters, and determine there are
375+
-- two cursor positions in the text.
376+
-- First, it will normalise the text to:
377+
--
378+
-- @
379+
-- 0: foo = 2
380+
-- 1: bar =
381+
-- @
382+
--
383+
-- stripping away the '^' characters. Then, the actual cursor positions are:
384+
--
385+
-- * (0, 1) and
386+
-- * (2, 5).
387+
--
388+
extractCursorPositions :: T.Text -> (T.Text, [PosPrefixInfo])
389+
extractCursorPositions t =
390+
let
391+
textLines = T.lines t
392+
foldState = List.foldl' go emptyFoldState textLines
393+
finalText = foldStateToText foldState
394+
reconstructCompletionPrefix pos = getCompletionPrefixFromRope pos (Rope.fromText finalText)
395+
cursorPositions = reverse . fmap reconstructCompletionPrefix $ foldStatePositions foldState
396+
in
397+
(finalText, cursorPositions)
398+
399+
where
400+
go foldState l = case T.indices "^" l of
401+
[] -> addTextLine foldState l
402+
xs -> List.foldl' addTextCursor foldState xs
403+
404+
-- | 'FoldState' is an implementation detail used to parse some file contents,
405+
-- extracting the cursor positions identified by '^' and producing a cleaned
406+
-- representation of the file contents.
407+
data FoldState = FoldState
408+
{ foldStateRows :: !Int
409+
-- ^ The row index of the cleaned file contents.
410+
--
411+
-- For example, the file contents
412+
--
413+
-- @
414+
-- 0: foo
415+
-- 1: ^
416+
-- 2: bar
417+
-- @
418+
-- will report that 'bar' is actually occurring in line '1', as '^' is
419+
-- a cursor position.
420+
-- Lines containing cursor positions are removed.
421+
, foldStatePositions :: ![Position]
422+
-- ^ List of cursors positions found in the file contents.
423+
--
424+
-- List is stored in reverse for efficient 'cons'ing
425+
, foldStateFinalText :: ![T.Text]
426+
-- ^ Final file contents with all lines containing cursor positions removed.
427+
--
428+
-- List is stored in reverse for efficient 'cons'ing
429+
}
430+
431+
emptyFoldState :: FoldState
432+
emptyFoldState = FoldState
433+
{ foldStateRows = 0
434+
, foldStatePositions = []
435+
, foldStateFinalText = []
436+
}
437+
438+
-- | Produce the final file contents, without any lines containing cursor positions.
439+
foldStateToText :: FoldState -> T.Text
440+
foldStateToText state = T.unlines $ reverse $ foldStateFinalText state
441+
442+
-- | We found a '^' at some location! Add it to the list of known cursor positions.
443+
--
444+
-- If the row index is '0', we throw an error, as there can't be a cursor position above the first line.
445+
addTextCursor :: FoldState -> Int -> FoldState
446+
addTextCursor state col
447+
| foldStateRows state <= 0 = error $ "addTextCursor: Invalid '^' found at: " <> show (col, foldStateRows state)
448+
| otherwise = state
449+
{ foldStatePositions = Position (fromIntegral (foldStateRows state) - 1) (fromIntegral col) : foldStatePositions state
450+
}
451+
452+
addTextLine :: FoldState -> T.Text -> FoldState
453+
addTextLine state l = state
454+
{ foldStateFinalText = l : foldStateFinalText state
455+
, foldStateRows = foldStateRows state + 1
456+
}

0 commit comments

Comments
 (0)