Skip to content

Commit def18b4

Browse files
authored
Improving Notes Plugin (#4791)
* hover Note References to get declaration info * Note Declaration snippet * Note References * update config and format code * Implement Reviews
1 parent a5f9481 commit def18b4

13 files changed

+384
-13
lines changed

plugins/hls-notes-plugin/src/Ide/Plugin/Notes.hs

Lines changed: 199 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import Data.HashMap.Strict (HashMap)
1010
import qualified Data.HashMap.Strict as HM
1111
import qualified Data.HashSet as HS
1212
import Data.List (uncons)
13-
import Data.Maybe (catMaybes, listToMaybe,
14-
mapMaybe)
13+
import Data.Maybe (catMaybes, fromMaybe,
14+
listToMaybe, mapMaybe)
1515
import Data.Text (Text, intercalate)
1616
import qualified Data.Text as T
1717
import qualified Data.Text.Utf16.Rope.Mixed as Rope
@@ -25,8 +25,8 @@ import GHC.Generics (Generic)
2525
import Ide.Plugin.Error (PluginError (..))
2626
import Ide.Types
2727
import qualified Language.LSP.Protocol.Lens as L
28-
import Language.LSP.Protocol.Message (Method (Method_TextDocumentDefinition, Method_TextDocumentReferences),
29-
SMethod (SMethod_TextDocumentDefinition, SMethod_TextDocumentReferences))
28+
import Language.LSP.Protocol.Message (Method (Method_TextDocumentCompletion, Method_TextDocumentDefinition, Method_TextDocumentHover, Method_TextDocumentReferences),
29+
SMethod (SMethod_TextDocumentCompletion, SMethod_TextDocumentDefinition, SMethod_TextDocumentHover, SMethod_TextDocumentReferences))
3030
import Language.LSP.Protocol.Types
3131
import Text.Regex.TDFA (Regex, caseSensitive,
3232
defaultCompOpt,
@@ -80,6 +80,8 @@ descriptor recorder plId = (defaultPluginDescriptor plId "Provides goto definiti
8080
, Ide.Types.pluginHandlers =
8181
mkPluginHandler SMethod_TextDocumentDefinition jumpToNote
8282
<> mkPluginHandler SMethod_TextDocumentReferences listReferences
83+
<> mkPluginHandler SMethod_TextDocumentHover hoverNote
84+
<> mkPluginHandler SMethod_TextDocumentCompletion autocomplete
8385
}
8486

8587
findNotesRules :: Recorder (WithPriority Log) -> Rules ()
@@ -190,8 +192,199 @@ findNotesInFile file recorder = do
190192

191193
noteRefRegex, noteRegex :: Regex
192194
(noteRefRegex, noteRegex) =
193-
( mkReg ("note \\[(.+)\\]" :: String)
194-
, mkReg ("note \\[([[:print:]]+)\\][[:blank:]]*\r?\n[[:blank:]]*(--)?[[:blank:]]*~~~" :: String)
195+
( mkReg ("note[[:blank:]]?\\[(.+)\\]" :: String)
196+
, mkReg ("note[[:blank:]]?\\[([[:print:]]+)\\][[:blank:]]*\r?\n[[:blank:]]*(--)?[[:blank:]]*~~~" :: String)
195197
)
196198
where
197199
mkReg = makeRegexOpts (defaultCompOpt { caseSensitive = False }) defaultExecOpt
200+
201+
-- | Find the precise range of `note[...]` or `note [...]` on a line.
202+
findNoteRange
203+
:: Text -- ^ Full line text
204+
-> Text -- ^ Note title
205+
-> UInt -- ^ Line number
206+
-> Maybe Range
207+
findNoteRange line _note lineNo =
208+
case matchAllText noteRefRegex line of
209+
[] -> Nothing
210+
(arr:_) ->
211+
case arr A.! 0 of
212+
(_, (start, len)) ->
213+
let startCol = fromIntegral start
214+
endCol = startCol + fromIntegral len
215+
in Just $
216+
Range
217+
(Position lineNo startCol)
218+
(Position lineNo endCol)
219+
220+
-- Given the path and position of a Note Declaration, finds Content in it.
221+
-- ignores ~ as a seprator
222+
extractNoteContent
223+
:: NormalizedFilePath
224+
-> Position
225+
-> IO (Maybe Text)
226+
extractNoteContent nfp (Position startLine _) = do
227+
fileText <- readFileUtf8 $ fromNormalizedFilePath nfp
228+
229+
let allLines = T.lines fileText
230+
afterDecl = drop (fromIntegral startLine + 1) allLines
231+
afterSeparator =
232+
case dropWhile (not . isSeparatorLine) afterDecl of
233+
[] -> []
234+
(_:xs) -> xs
235+
236+
bodyLines = takeWhile (not . isStopLine) afterSeparator
237+
238+
if null afterSeparator
239+
then pure Nothing
240+
else pure $ Just (T.unlines (map stripCommentPrefix bodyLines))
241+
242+
where
243+
isSeparatorLine :: Text -> Bool
244+
isSeparatorLine t = "~~~" `T.isInfixOf` t
245+
246+
isNoteHeader :: Text -> Bool
247+
isNoteHeader t =
248+
let stripped = T.toLower (T.stripStart (stripCommentPrefix t))
249+
in "note [" `T.isPrefixOf` stripped
250+
251+
isEndMarker :: Text -> Bool
252+
isEndMarker t = "-}" `T.isInfixOf` t
253+
254+
isStopLine :: Text -> Bool
255+
isStopLine t = isSeparatorLine t || isNoteHeader t || isEndMarker t
256+
257+
stripCommentPrefix :: Text -> Text
258+
stripCommentPrefix t =
259+
let s = T.stripStart t
260+
in if "--" `T.isPrefixOf` s
261+
then T.stripStart (T.drop 2 s)
262+
else s
263+
264+
normalizeNewlines :: Text -> Text
265+
normalizeNewlines = T.replace "\r\n" "\n"
266+
267+
-- on hovering Note References, shows corresponding Declaration if it Exists
268+
-- ignores Note Declaration
269+
hoverNote :: PluginMethodHandler IdeState Method_TextDocumentHover
270+
hoverNote state _ params
271+
| Just nfp <- uriToNormalizedFilePath uriOrig
272+
= do
273+
let pos@(Position line _) = params ^. L.position
274+
noteOpt <- getNote nfp state pos
275+
case noteOpt of
276+
Nothing -> pure (InR Null)
277+
278+
Just note -> do
279+
mbRope <- liftIO $ runAction "notes.hoverLine" state (getFileContents nfp)
280+
281+
-- compute precise hover range for highlighting corresponding Note Reference on Hover
282+
let lineText =
283+
case mbRope of
284+
Nothing -> ""
285+
Just rope -> fromMaybe "" $ listToMaybe $ drop (fromIntegral line) $ Rope.lines rope
286+
287+
mbRange = findNoteRange lineText note line
288+
289+
notes <- runActionE "notes.hover" state $ useE MkGetNotes nfp
290+
case HM.lookup note notes of
291+
Nothing -> pure $ InL $ Hover (InL $ MarkupContent MarkupKind_Markdown "_No declaration available_") mbRange
292+
293+
Just (defFile, defPos) ->
294+
if nfp == defFile && pos ^. L.line == defPos ^. L.line
295+
then pure (InR Null)
296+
else do
297+
mbContent <- liftIO $ extractNoteContent defFile defPos
298+
299+
let contents =
300+
case mbContent of
301+
Nothing -> "_No declaration available_"
302+
Just body -> T.unlines [note, "", body]
303+
normalizedContents = normalizeNewlines contents
304+
305+
pure $ InL $ Hover (InL $ MarkupContent MarkupKind_Markdown normalizedContents) mbRange
306+
where
307+
uriOrig = toNormalizedUri $ params ^. (L.textDocument . L.uri)
308+
309+
hoverNote _ _ _ = pure (InR Null)
310+
311+
-- Gives an autocomplete suggestion when 'note' prefix is detected
312+
autocomplete :: PluginMethodHandler IdeState Method_TextDocumentCompletion
313+
autocomplete state _ params = do
314+
let uri = params ^. (L.textDocument . L.uri)
315+
pos = params ^. L.position
316+
nuri = toNormalizedUri uri
317+
318+
contents <-
319+
liftIO $
320+
runAction "Notes.GetUriContents" state $
321+
getUriContents nuri
322+
323+
fmap InL $
324+
case contents of
325+
Nothing -> pure []
326+
327+
Just rope -> do
328+
let linePrefix = T.toLower $ T.stripEnd $ getLinePrefix rope pos
329+
330+
-- Suggest NOTE DECLARATION snippit if "note" prefix detected
331+
if T.strip linePrefix == "note"
332+
then
333+
pure [CompletionItem "Note" Nothing (Just CompletionItemKind_Keyword) Nothing
334+
(Just "Note Declaration") Nothing Nothing Nothing Nothing
335+
Nothing (Just noteSnippet) (Just InsertTextFormat_Snippet) Nothing
336+
Nothing Nothing Nothing Nothing Nothing Nothing
337+
]
338+
339+
-- Suggest list of all NOTE DECLARATION if "note [" infix detected
340+
else if "note[" `T.isInfixOf` linePrefix || "note [" `T.isInfixOf` linePrefix
341+
then
342+
case uriToNormalizedFilePath nuri of
343+
Nothing -> pure []
344+
345+
Just nfp -> do
346+
let typed =
347+
case T.breakOnEnd "[" linePrefix of
348+
(_, "") -> ""
349+
(_, rest)-> T.strip rest
350+
351+
notesMap <-
352+
runActionE "notes.completion.notes" state $
353+
useE MkGetNotes nfp
354+
355+
let allNotes = HM.keys notesMap
356+
matches =
357+
filter
358+
(\n -> T.toLower typed `T.isPrefixOf` T.toLower n)
359+
allNotes
360+
361+
finalNotes =
362+
if null matches then allNotes else matches
363+
pure $
364+
map
365+
(\n ->
366+
CompletionItem n Nothing (Just CompletionItemKind_Reference) Nothing (Just "Note reference")
367+
Nothing Nothing (Just True) (Just "0") (Just n)
368+
Nothing Nothing Nothing Nothing Nothing
369+
Nothing Nothing Nothing Nothing
370+
)
371+
finalNotes
372+
else
373+
pure []
374+
375+
noteSnippet :: Text
376+
noteSnippet =
377+
T.unlines
378+
[ "{- Note [${1:Declaration title}]"
379+
, "~~~"
380+
, "${2:Content}"
381+
, "-}"
382+
]
383+
384+
getLinePrefix :: Rope.Rope -> Position -> Text
385+
getLinePrefix rope (Position line col) =
386+
case Rope.splitAtLine (fromIntegral line) rope of
387+
(_, rest) ->
388+
case Rope.lines rest of
389+
(l:_) -> T.take (fromIntegral col) l
390+
_ -> ""

plugins/hls-notes-plugin/test/NotesTest.hs

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
module Main (main) where
22

3+
import Data.List (sort)
34
import Ide.Plugin.Notes (Log, descriptor)
45
import System.FilePath ((</>))
56
import Test.Hls
@@ -12,6 +13,7 @@ main = defaultTestRunner $
1213
testGroup "Notes"
1314
[ gotoNoteTests
1415
, noteReferenceTests
16+
, hoverNoteTests
1517
]
1618

1719
runSessionWithServer' :: FilePath -> (FilePath -> Session a) -> IO a
@@ -28,13 +30,15 @@ noteReferenceTests = testGroup "Note References"
2830
testCase "multi_file" $ runSessionWithServer' testDataDir $ \dir -> do
2931
doc <- openDoc "NoteDef.hs" "haskell"
3032
waitForKickDone
31-
refs <- getReferences doc (Position 21 15) False
32-
let fp = dir </> "NoteDef.hs"
33-
liftIO $ refs @?= [
34-
Location (filePathToUri (dir </> "Other.hs")) (Range (Position 6 13) (Position 6 13)),
35-
Location (filePathToUri fp) (Range (Position 9 9) (Position 9 9)),
36-
Location (filePathToUri fp) (Range (Position 5 67) (Position 5 67))
37-
]
33+
refs0 <- getReferences doc (Position 21 15) False
34+
let refs = sort refs0
35+
fp = dir </> "NoteDef.hs"
36+
37+
liftIO $ sort refs @?= sort
38+
[ Location (filePathToUri (dir </> "Other.hs")) (Range (Position 6 13) (Position 6 13))
39+
, Location (filePathToUri fp) (Range (Position 9 9) (Position 9 9))
40+
, Location (filePathToUri fp) (Range (Position 5 67) (Position 5 67))
41+
]
3842
]
3943

4044
gotoNoteTests :: TestTree
@@ -75,3 +79,69 @@ gotoNoteTests = testGroup "Goto Note Definition"
7579

7680
testDataDir :: FilePath
7781
testDataDir = "plugins" </> "hls-notes-plugin" </> "test" </> "testdata"
82+
83+
hoverNoteTests :: TestTree
84+
hoverNoteTests = testGroup "Hover Notes"
85+
[ testCase "hover normal-notes" $
86+
runSessionWithServer' testDataDir $ \_dir -> do
87+
let file = "HoverNote.hs"
88+
pos = Position 24 10
89+
doc <- openDoc file "haskell"
90+
waitForKickDone
91+
hover <- getHover doc pos
92+
93+
let expected =
94+
Just $ Hover (InL $ MarkupContent MarkupKind_Markdown
95+
"Normal-Notes\n\nContent of Normal Notes\n\n")
96+
(Just (Range (Position 24 3) (Position 24 22)))
97+
98+
liftIO $ hover @?= expected
99+
100+
101+
, testCase "hover multi-notes-one" $
102+
runSessionWithServer' testDataDir $ \_dir -> do
103+
let file = "HoverNote.hs"
104+
pos = Position 25 10
105+
doc <- openDoc file "haskell"
106+
waitForKickDone
107+
hover <- getHover doc pos
108+
109+
let expected =
110+
Just $ Hover (InL $ MarkupContent MarkupKind_Markdown
111+
"Multi-Notes-one\n\nContent of Multi-Notes-one\n\n\n")
112+
(Just (Range (Position 25 3) (Position 25 25)))
113+
114+
liftIO $ hover @?= expected
115+
116+
117+
, testCase "hover multi-notes-two" $
118+
runSessionWithServer' testDataDir $ \_dir -> do
119+
let file = "HoverNote.hs"
120+
pos = Position 26 10
121+
doc <- openDoc file "haskell"
122+
waitForKickDone
123+
hover <- getHover doc pos
124+
125+
let expected =
126+
Just $ Hover (InL $ MarkupContent MarkupKind_Markdown
127+
"Multi-Notes-two\n\nContent of Multi-Notes-two\n\n")
128+
(Just (Range (Position 26 3) (Position 26 25)))
129+
130+
liftIO $ hover @?= expected
131+
132+
133+
, testCase "hover single-comment-declaration" $
134+
runSessionWithServer' testDataDir $ \_dir -> do
135+
let file = "HoverNote.hs"
136+
pos = Position 27 10
137+
doc <- openDoc file "haskell"
138+
waitForKickDone
139+
hover <- getHover doc pos
140+
141+
let expected =
142+
Just $ Hover (InL $ MarkupContent MarkupKind_Markdown
143+
"Single Comment Declaration\n\nContent of Single Comment Declaration\n\n")
144+
(Just (Range (Position 27 3) (Position 27 36)))
145+
146+
liftIO $ hover @?= expected
147+
]
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
module HoverNotes where
2+
3+
{- Note [Normal-Notes]
4+
~~~
5+
Content of Normal Notes
6+
-}
7+
8+
{- Note [Multi-Notes-one]
9+
~~~
10+
Content of Multi-Notes-one
11+
12+
Note [Multi-Notes-two]
13+
~~~
14+
Content of Multi-Notes-two
15+
-}
16+
17+
-- Note [Single Comment Declaration]
18+
-- ~~~
19+
-- Content of Single Comment Declaration
20+
-- -}
21+
22+
foo :: Int
23+
foo = 42
24+
25+
-- Note [Normal-Notes]
26+
-- Note [Multi-Notes-one]
27+
-- Note [Multi-Notes-two]
28+
-- Note [Single Comment Declaration]

test/testdata/schema/ghc910/default-config.golden.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@
9999
"moduleName": {
100100
"globalOn": true
101101
},
102+
"notes": {
103+
"completionOn": true,
104+
"hoverOn": true
105+
},
102106
"ormolu": {
103107
"config": {
104108
"external": false

test/testdata/schema/ghc910/vscode-extension-schema.golden.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,18 @@
237237
"scope": "resource",
238238
"type": "boolean"
239239
},
240+
"haskell.plugin.notes.completionOn": {
241+
"default": true,
242+
"description": "Enables notes completions",
243+
"scope": "resource",
244+
"type": "boolean"
245+
},
246+
"haskell.plugin.notes.hoverOn": {
247+
"default": true,
248+
"description": "Enables notes hover",
249+
"scope": "resource",
250+
"type": "boolean"
251+
},
240252
"haskell.plugin.ormolu.config.external": {
241253
"default": false,
242254
"markdownDescription": "Call out to an external \"ormolu\" executable, rather than using the bundled library",

0 commit comments

Comments
 (0)