Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit c5711f4

Browse files
committedMay 27, 2024
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. Further, we extend the known rules for the cabal plugin, to avoid expensive reparsing using `readFields`.
1 parent 44a2f1e commit c5711f4

File tree

9 files changed

+427
-228
lines changed

9 files changed

+427
-228
lines changed
 

‎haskell-language-server.cabal

Lines changed: 1 addition & 0 deletions
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

‎plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal.hs

Lines changed: 74 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import Control.DeepSeq
1111
import Control.Lens ((^.))
1212
import Control.Monad.Extra
1313
import Control.Monad.IO.Class
14-
import Control.Monad.Trans.Class (lift)
14+
import Control.Monad.Trans.Class
1515
import Control.Monad.Trans.Maybe (runMaybeT)
1616
import qualified Data.ByteString as BS
1717
import Data.Hashable
@@ -27,12 +27,17 @@ import Development.IDE.Graph (Key, alwaysRerun)
2727
import qualified Development.IDE.Plugin.Completions.Logic as Ghcide
2828
import qualified Development.IDE.Plugin.Completions.Types as Ghcide
2929
import Development.IDE.Types.Shake (toKey)
30+
import qualified Distribution.Fields as Syntax
31+
import qualified Distribution.Parsec.Position as Syntax
3032
import GHC.Generics
3133
import qualified Ide.Plugin.Cabal.Completion.Completer.Types as CompleterTypes
3234
import qualified Ide.Plugin.Cabal.Completion.Completions as Completions
35+
import Ide.Plugin.Cabal.Completion.Types (ParseCabalFields (..),
36+
ParseCabalFile (..))
3337
import qualified Ide.Plugin.Cabal.Completion.Types as Types
3438
import qualified Ide.Plugin.Cabal.Diagnostics as Diagnostics
3539
import qualified Ide.Plugin.Cabal.LicenseSuggest as LicenseSuggest
40+
import Ide.Plugin.Cabal.Orphans ()
3641
import qualified Ide.Plugin.Cabal.Parse as Parse
3742
import Ide.Types
3843
import qualified Language.LSP.Protocol.Lens as JL
@@ -70,7 +75,7 @@ instance Pretty Log where
7075
"Set files of interest to:" <+> viaShow files
7176
LogCompletionContext context position ->
7277
"Determined completion context:"
73-
<+> viaShow context
78+
<+> pretty context
7479
<+> "for cursor position:"
7580
<+> pretty position
7681
LogCompletions logs -> pretty logs
@@ -145,30 +150,55 @@ cabalRules recorder plId = do
145150
-- Make sure we initialise the cabal files-of-interest.
146151
ofInterestRules recorder
147152
-- Rule to produce diagnostics for cabal files.
148-
define (cmapWithPrio LogShake recorder) $ \Types.GetCabalDiagnostics file -> do
153+
define (cmapWithPrio LogShake recorder) $ \ParseCabalFields file -> do
149154
config <- getPluginConfigAction plId
150155
if not (plcGlobalOn config && plcDiagnosticsOn config)
151-
then pure ([], Nothing)
152-
else do
153-
-- whenever this key is marked as dirty (e.g., when a user writes stuff to it),
154-
-- we rerun this rule because this rule *depends* on GetModificationTime.
155-
(t, mCabalSource) <- use_ GetFileContents file
156-
log' Debug $ LogModificationTime file t
157-
contents <- case mCabalSource of
158-
Just sources ->
159-
pure $ Encoding.encodeUtf8 sources
160-
Nothing -> do
161-
liftIO $ BS.readFile $ fromNormalizedFilePath file
162-
163-
(pWarnings, pm) <- liftIO $ Parse.parseCabalFileContents contents
164-
let warningDiags = fmap (Diagnostics.warningDiagnostic file) pWarnings
165-
case pm of
166-
Left (_cabalVersion, pErrorNE) -> do
167-
let errorDiags = NE.toList $ NE.map (Diagnostics.errorDiagnostic file) pErrorNE
168-
allDiags = errorDiags <> warningDiags
169-
pure (allDiags, Nothing)
170-
Right gpd -> do
171-
pure (warningDiags, Just gpd)
156+
then pure ([], Nothing)
157+
else do
158+
-- whenever this key is marked as dirty (e.g., when a user writes stuff to it),
159+
-- we rerun this rule because this rule *depends* on GetModificationTime.
160+
(t, mCabalSource) <- use_ GetFileContents file
161+
log' Debug $ LogModificationTime file t
162+
contents <- case mCabalSource of
163+
Just sources ->
164+
pure $ Encoding.encodeUtf8 sources
165+
Nothing -> do
166+
liftIO $ BS.readFile $ fromNormalizedFilePath file
167+
168+
case Parse.readCabalFields file contents of
169+
Left _ ->
170+
pure ([], Nothing)
171+
Right fields ->
172+
pure ([], Just fields)
173+
174+
define (cmapWithPrio LogShake recorder) $ \ParseCabalFile file -> do
175+
config <- getPluginConfigAction plId
176+
if not (plcGlobalOn config && plcDiagnosticsOn config)
177+
then pure ([], Nothing)
178+
else do
179+
-- whenever this key is marked as dirty (e.g., when a user writes stuff to it),
180+
-- we rerun this rule because this rule *depends* on GetModificationTime.
181+
(t, mCabalSource) <- use_ GetFileContents file
182+
log' Debug $ LogModificationTime file t
183+
contents <- case mCabalSource of
184+
Just sources ->
185+
pure $ Encoding.encodeUtf8 sources
186+
Nothing -> do
187+
liftIO $ BS.readFile $ fromNormalizedFilePath file
188+
189+
-- Instead of fully reparsing the sources to get a 'GenericPackageDescription',
190+
-- we would much rather re-use the already parsed results of 'ParseCabalFields'.
191+
-- Unfortunately, Cabal-syntax doesn't expose the function 'parseGenericPackageDescription''
192+
-- which allows us to resume the parsing pipeline with '[Field Position]'.
193+
(pWarnings, pm) <- liftIO $ Parse.parseCabalFileContents contents
194+
let warningDiags = fmap (Diagnostics.warningDiagnostic file) pWarnings
195+
case pm of
196+
Left (_cabalVersion, pErrorNE) -> do
197+
let errorDiags = NE.toList $ NE.map (Diagnostics.errorDiagnostic file) pErrorNE
198+
allDiags = errorDiags <> warningDiags
199+
pure (allDiags, Nothing)
200+
Right gpd -> do
201+
pure (warningDiags, Just gpd)
172202

173203
action $ do
174204
-- Run the cabal kick. This code always runs when 'shakeRestart' is run.
@@ -188,7 +218,7 @@ function invocation.
188218
kick :: Action ()
189219
kick = do
190220
files <- HashMap.keys <$> getCabalFilesOfInterestUntracked
191-
void $ uses Types.GetCabalDiagnostics files
221+
void $ uses Types.ParseCabalFile files
192222

193223
-- ----------------------------------------------------------------
194224
-- Code Actions
@@ -281,24 +311,31 @@ completion :: Recorder (WithPriority Log) -> PluginMethodHandler IdeState 'LSP.M
281311
completion recorder ide _ complParams = do
282312
let (TextDocumentIdentifier uri) = complParams ^. JL.textDocument
283313
position = complParams ^. JL.position
284-
contents <- lift $ getVirtualFile $ toNormalizedUri uri
285-
case (contents, uriToFilePath' uri) of
286-
(Just cnts, Just path) -> do
287-
let pref = Ghcide.getCompletionPrefix position cnts
288-
let res = result pref path cnts
289-
liftIO $ fmap InL res
290-
_ -> pure . InR $ InR Null
314+
mVf <- lift $ getVirtualFile $ toNormalizedUri uri
315+
case (,) <$> mVf <*> uriToFilePath' uri of
316+
Just (cnts, path) -> do
317+
mFields <- liftIO $ runIdeAction "cabal-plugin.fields" (shakeExtras ide) $ useWithStaleFast ParseCabalFields $ toNormalizedFilePath path
318+
case mFields of
319+
Nothing ->
320+
pure . InR $ InR Null
321+
Just (fields, _) -> do
322+
let pref = Ghcide.getCompletionPrefix position cnts
323+
let res = produceCompletions pref path fields
324+
liftIO $ fmap InL res
325+
Nothing -> pure . InR $ InR Null
291326
where
292-
result :: Ghcide.PosPrefixInfo -> FilePath -> VFS.VirtualFile -> IO [CompletionItem]
293-
result prefix fp cnts = do
294-
runMaybeT context >>= \case
327+
completerRecorder = cmapWithPrio LogCompletions recorder
328+
329+
produceCompletions :: Ghcide.PosPrefixInfo -> FilePath -> [Syntax.Field Syntax.Position] -> IO [CompletionItem]
330+
produceCompletions prefix fp fields = do
331+
runMaybeT (context fields) >>= \case
295332
Nothing -> pure []
296333
Just ctx -> do
297334
logWith recorder Debug $ LogCompletionContext ctx pos
298335
let completer = Completions.contextToCompleter ctx
299336
let completerData = CompleterTypes.CompleterData
300337
{ getLatestGPD = do
301-
mGPD <- runIdeAction "cabal-plugin.modulesCompleter.gpd" (shakeExtras ide) $ useWithStaleFast Types.GetCabalDiagnostics $ toNormalizedFilePath fp
338+
mGPD <- runIdeAction "cabal-plugin.modulesCompleter.gpd" (shakeExtras ide) $ useWithStaleFast ParseCabalFile $ toNormalizedFilePath fp
302339
pure $ fmap fst mGPD
303340
, cabalPrefixInfo = prefInfo
304341
, stanzaName =
@@ -309,7 +346,6 @@ completion recorder ide _ complParams = do
309346
completions <- completer completerRecorder completerData
310347
pure completions
311348
where
312-
completerRecorder = cmapWithPrio LogCompletions recorder
313349
pos = Ghcide.cursorPos prefix
314-
context = Completions.getContext completerRecorder prefInfo (cnts ^. VFS.file_text)
350+
context fields = Completions.getContext completerRecorder prefInfo fields
315351
prefInfo = Completions.getCabalPrefixInfo fp prefix

‎plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/Completion/Completions.hs

Lines changed: 115 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,15 @@ module Ide.Plugin.Cabal.Completion.Completions (contextToCompleter, getContext,
44

55
import Control.Lens ((^.))
66
import Control.Monad.IO.Class (MonadIO)
7-
import Control.Monad.Trans.Maybe
8-
import Data.Foldable (asum)
9-
import qualified Data.List as List
10-
import Data.Map (Map)
7+
import Data.List.NonEmpty (NonEmpty)
8+
import qualified Data.List.NonEmpty as NE
119
import qualified Data.Map as Map
1210
import qualified Data.Text as T
13-
import qualified Data.Text.Utf16.Lines as Rope (Position (..))
14-
import Data.Text.Utf16.Rope.Mixed (Rope)
15-
import qualified Data.Text.Utf16.Rope.Mixed as Rope
11+
import qualified Data.Text.Encoding as T
1612
import Development.IDE as D
1713
import qualified Development.IDE.Plugin.Completions.Types as Ghcide
14+
import qualified Distribution.Fields as Syntax
15+
import qualified Distribution.Parsec.Position as Syntax
1816
import Ide.Plugin.Cabal.Completion.Completer.Simple
1917
import Ide.Plugin.Cabal.Completion.Completer.Snippet
2018
import Ide.Plugin.Cabal.Completion.Completer.Types (Completer)
@@ -64,32 +62,13 @@ contextToCompleter (Stanza s _, KeyWord kw) =
6462
-- Can return Nothing if an error occurs.
6563
--
6664
-- TODO: first line can only have cabal-version: keyword
67-
getContext :: (MonadIO m) => Recorder (WithPriority Log) -> CabalPrefixInfo -> Rope -> MaybeT m Context
68-
getContext recorder prefInfo ls =
69-
case prevLinesM of
70-
Just prevLines -> do
71-
let lvlContext =
72-
if completionIndentation prefInfo == 0
73-
then TopLevel
74-
else currentLevel prevLines
75-
case lvlContext of
76-
TopLevel -> do
77-
kwContext <- MaybeT . pure $ getKeyWordContext prefInfo prevLines (cabalVersionKeyword <> cabalKeywords)
78-
pure (TopLevel, kwContext)
79-
Stanza s n ->
80-
case Map.lookup s stanzaKeywordMap of
81-
Nothing -> do
82-
pure (Stanza s n, None)
83-
Just m -> do
84-
kwContext <- MaybeT . pure $ getKeyWordContext prefInfo prevLines m
85-
pure (Stanza s n, kwContext)
86-
Nothing -> do
87-
logWith recorder Warning $ LogFileSplitError pos
88-
-- basically returns nothing
89-
fail "Abort computation"
65+
getContext :: (MonadIO m) => Recorder (WithPriority Log) -> CabalPrefixInfo -> [Syntax.Field Syntax.Position] -> m Context
66+
getContext recorder prefInfo fields = do
67+
let ctx = findCursorContext cursor (NE.singleton (0, TopLevel)) (completionPrefix prefInfo) fields
68+
logWith recorder Debug $ LogCompletionContext ctx
69+
pure ctx
9070
where
91-
pos = completionCursorPosition prefInfo
92-
prevLinesM = splitAtPosition pos ls
71+
cursor = lspPositionToCabalPosition (completionCursorPosition prefInfo)
9372

9473
-- | Takes information about the current file's file path,
9574
-- and the cursor position in the file; and builds a CabalPrefixInfo
@@ -144,84 +123,111 @@ getCabalPrefixInfo fp prefixInfo =
144123
-- Implementation Details
145124
-- ----------------------------------------------------------------
146125

147-
-- | Takes prefix info about the previously written text,
148-
-- a list of lines (representing a file) and a map of
149-
-- keywords and returns a keyword context if the
150-
-- previously written keyword matches one in the map.
126+
findCursorContext ::
127+
Syntax.Position ->
128+
-- ^ The cursor position we look for in the fields
129+
NonEmpty (Int, StanzaContext) ->
130+
-- ^ A stack of current stanza contexts and their starting line numbers
131+
T.Text ->
132+
-- ^ The cursor's prefix text
133+
[Syntax.Field Syntax.Position] ->
134+
-- ^ The fields to traverse
135+
Context
136+
findCursorContext cursor parentHistory prefixText fields =
137+
case findFieldSection cursor fields of
138+
Nothing -> (snd $ NE.head parentHistory, None)
139+
-- We found the most likely section. Now, are we starting a new section or are we completing an existing one?
140+
Just field@(Syntax.Field _ _) -> classifyFieldContext parentHistory cursor field
141+
Just section@(Syntax.Section _ args sectionFields)
142+
| inSameLineAsSectionName section -> (stanzaCtx, None) -- TODO: test whether keyword in same line is parsed correctly
143+
| otherwise ->
144+
findCursorContext cursor
145+
(NE.cons (Syntax.positionCol (getAnnotation section) + 1, Stanza (getFieldName section) (getOptionalSectionName args)) parentHistory)
146+
prefixText sectionFields
147+
where
148+
inSameLineAsSectionName section = Syntax.positionRow (getAnnotation section) == Syntax.positionRow cursor
149+
stanzaCtx = snd $ NE.head parentHistory
150+
151+
-- | Finds the cursor's context, where the cursor is already found to be in a specific field
151152
--
152-
-- From a cursor position, we traverse the cabal file upwards to
153-
-- find the latest written keyword if there is any.
154-
-- Values may be written on subsequent lines,
155-
-- in order to allow for this we take the indentation of the current
156-
-- word to be completed into account to find the correct keyword context.
157-
getKeyWordContext :: CabalPrefixInfo -> [T.Text] -> Map KeyWordName a -> Maybe FieldContext
158-
getKeyWordContext prefInfo ls keywords = do
159-
case lastNonEmptyLineM of
160-
Nothing -> Just None
161-
Just lastLine' -> do
162-
let (whiteSpaces, lastLine) = T.span (== ' ') lastLine'
163-
let keywordIndentation = T.length whiteSpaces
164-
let cursorIndentation = completionIndentation prefInfo
165-
-- in order to be in a keyword context the cursor needs
166-
-- to be indented more than the keyword
167-
if cursorIndentation > keywordIndentation
168-
then -- if the last thing written was a keyword without a value
169-
case List.find (`T.isPrefixOf` lastLine) (Map.keys keywords) of
170-
Nothing -> Just None
171-
Just kw -> Just $ KeyWord kw
172-
else Just None
153+
-- Due to the way the field context is recognised for incomplete cabal files,
154+
-- an incomplete keyword is also recognised as a field, therefore we need to determine
155+
-- the specific context as we could still be in a stanza context in this case.
156+
classifyFieldContext :: NonEmpty (Int, StanzaContext) -> Syntax.Position -> Syntax.Field Syntax.Position -> Context
157+
classifyFieldContext ctx cursor field
158+
-- the cursor is not indented enough to be within the field
159+
-- but still indented enough to be within the stanza
160+
| cursorColumn <= fieldColumn && minIndent <= cursorColumn = (stanzaCtx, None)
161+
-- the cursor is not in the current stanza's context as it is not indented enough
162+
| cursorColumn < minIndent = findStanzaForColumn cursorColumn ctx
163+
| cursorIsInFieldName = (stanzaCtx, None)
164+
| cursorIsBeforeFieldName = (stanzaCtx, None)
165+
| otherwise = (stanzaCtx, KeyWord (getFieldName field <> ":"))
173166
where
174-
lastNonEmptyLineM :: Maybe T.Text
175-
lastNonEmptyLineM = do
176-
(curLine, rest) <- List.uncons ls
177-
-- represents the current line while disregarding the
178-
-- currently written text we want to complete
179-
let cur = stripPartiallyWritten curLine
180-
List.find (not . T.null . T.stripEnd) $
181-
cur : rest
182-
183-
-- | Traverse the given lines (starting before current cursor position
184-
-- up to the start of the file) to find the nearest stanza declaration,
185-
-- if none is found we are in the top level context.
167+
(minIndent, stanzaCtx) = NE.head ctx
168+
169+
cursorIsInFieldName = inSameLineAsFieldName &&
170+
fieldColumn <= cursorColumn &&
171+
cursorColumn <= fieldColumn + T.length (getFieldName field)
172+
173+
cursorIsBeforeFieldName = inSameLineAsFieldName &&
174+
cursorColumn < fieldColumn
175+
176+
inSameLineAsFieldName = Syntax.positionRow (getAnnotation field) == Syntax.positionRow cursor
177+
178+
cursorColumn = Syntax.positionCol cursor
179+
fieldColumn = Syntax.positionCol (getAnnotation field)
180+
181+
-- ----------------------------------------------------------------
182+
-- Cabal-syntax utilities I don't really want to write myself
183+
-- ----------------------------------------------------------------
184+
185+
-- | Determine the context of a cursor position within a stack of stanza contexts
186186
--
187-
-- TODO: this could be merged with getKeyWordContext in order to increase
188-
-- performance by reducing the number of times we have to traverse the cabal file.
189-
currentLevel :: [T.Text] -> StanzaContext
190-
currentLevel [] = TopLevel
191-
currentLevel (cur : xs)
192-
| Just (s, n) <- stanza = Stanza s n
193-
| otherwise = currentLevel xs
194-
where
195-
stanza = asum $ map checkStanza (Map.keys stanzaKeywordMap)
196-
checkStanza :: StanzaType -> Maybe (StanzaType, Maybe StanzaName)
197-
checkStanza t =
198-
case T.stripPrefix t (T.strip cur) of
199-
Just n
200-
| T.null n -> Just (t, Nothing)
201-
| otherwise -> Just (t, Just $ T.strip n)
202-
Nothing -> Nothing
203-
204-
-- | Get all lines before the given cursor position in the given file
205-
-- and reverse their order to traverse backwards starting from the given position.
206-
splitAtPosition :: Position -> Rope -> Maybe [T.Text]
207-
splitAtPosition pos ls = do
208-
split <- splitFile
209-
pure $ reverse $ Rope.lines $ fst split
210-
where
211-
splitFile = Rope.utf16SplitAtPosition ropePos ls
212-
ropePos =
213-
Rope.Position
214-
{ Rope.posLine = fromIntegral $ pos ^. JL.line,
215-
Rope.posColumn = fromIntegral $ pos ^. JL.character
216-
}
217-
218-
-- | Takes a line of text and removes the last partially
219-
-- written word to be completed.
220-
stripPartiallyWritten :: T.Text -> T.Text
221-
stripPartiallyWritten = T.dropWhileEnd (\y -> (y /= ' ') && (y /= ':'))
222-
223-
-- | Calculates how many spaces the currently completed item is indented.
224-
completionIndentation :: CabalPrefixInfo -> Int
225-
completionIndentation prefInfo = fromIntegral (pos ^. JL.character) - (T.length $ completionPrefix prefInfo)
187+
-- If the cursor is indented more than one of the stanzas in the stack
188+
-- the respective stanza is returned if this is never the case, the toplevel stanza
189+
-- in the stack is returned.
190+
findStanzaForColumn :: Int -> NonEmpty (Int, StanzaContext) -> (StanzaContext, FieldContext)
191+
findStanzaForColumn col ctx = case NE.uncons ctx of
192+
((_, stanza), Nothing) -> (stanza, None)
193+
((indentation, stanza), Just res)
194+
| col < indentation -> findStanzaForColumn col res
195+
| otherwise -> (stanza, None)
196+
197+
-- | Determine the field the cursor is currently a part of.
198+
--
199+
-- The result is said field and its starting position
200+
-- or Nothing if the passed list of fields is empty.
201+
202+
-- This only looks at the row of the cursor and not at the cursor's
203+
-- position within the row.
204+
--
205+
-- TODO: we do not handle braces correctly.
206+
findFieldSection :: Syntax.Position -> [Syntax.Field Syntax.Position] -> Maybe (Syntax.Field Syntax.Position)
207+
findFieldSection _cursor [] = Nothing
208+
findFieldSection _cursor [x] =
209+
-- Last field. We decide later, whether we are starting
210+
-- a new section.
211+
Just x
212+
findFieldSection cursor (x:y:ys)
213+
| Syntax.positionRow (getAnnotation x) <= cursorLine && cursorLine < Syntax.positionRow (getAnnotation y)
214+
= Just x
215+
| otherwise = findFieldSection cursor (y:ys)
226216
where
227-
pos = completionCursorPosition prefInfo
217+
cursorLine = Syntax.positionRow cursor
218+
219+
type FieldName = T.Text
220+
221+
getAnnotation :: Syntax.Field ann -> ann
222+
getAnnotation (Syntax.Field (Syntax.Name ann _) _) = ann
223+
getAnnotation (Syntax.Section (Syntax.Name ann _) _ _) = ann
224+
225+
getFieldName :: Syntax.Field ann -> FieldName
226+
getFieldName (Syntax.Field (Syntax.Name _ fn) _) = T.decodeUtf8 fn
227+
getFieldName (Syntax.Section (Syntax.Name _ fn) _ _) = T.decodeUtf8 fn
228+
229+
getOptionalSectionName :: [Syntax.SectionArg ann] -> Maybe T.Text
230+
getOptionalSectionName [] = Nothing
231+
getOptionalSectionName (x:xs) = case x of
232+
Syntax.SecArgName _ name -> Just (T.decodeUtf8 name)
233+
_ -> getOptionalSectionName xs

‎plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/Completion/Types.hs

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@
44

55
module Ide.Plugin.Cabal.Completion.Types where
66

7-
import Control.DeepSeq (NFData)
7+
import Control.DeepSeq (NFData)
8+
import Control.Lens ((^.))
89
import Data.Hashable
9-
import qualified Data.Text as T
10+
import qualified Data.Text as T
1011
import Data.Typeable
11-
import Development.IDE as D
12+
import Development.IDE as D
13+
import qualified Distribution.Fields as Syntax
14+
import qualified Distribution.PackageDescription as PD
15+
import qualified Distribution.Parsec.Position as Syntax
1216
import GHC.Generics
13-
import qualified Ide.Plugin.Cabal.Parse as Parse
17+
import qualified Language.LSP.Protocol.Lens as JL
1418

1519
data Log
1620
= LogFileSplitError Position
@@ -21,6 +25,7 @@ data Log
2125
| LogFilePathCompleterIOError FilePath IOError
2226
| LogUseWithStaleFastNoResult
2327
| LogMapLookUpOfKnownKeyFailed T.Text
28+
| LogCompletionContext Context
2429
deriving (Show)
2530

2631
instance Pretty Log where
@@ -34,15 +39,25 @@ instance Pretty Log where
3439
"When trying to complete the file path:" <+> pretty fp <+> "the following unexpected IO error occurred" <+> viaShow ioErr
3540
LogUseWithStaleFastNoResult -> "Package description couldn't be read"
3641
LogMapLookUpOfKnownKeyFailed key -> "Lookup of key in map failed even though it should exist" <+> pretty key
42+
LogCompletionContext ctx -> "Completion context is:" <+> pretty ctx
3743

38-
type instance RuleResult GetCabalDiagnostics = Parse.GenericPackageDescription
44+
type instance RuleResult ParseCabalFile = PD.GenericPackageDescription
3945

40-
data GetCabalDiagnostics = GetCabalDiagnostics
46+
data ParseCabalFile = ParseCabalFile
4147
deriving (Eq, Show, Typeable, Generic)
4248

43-
instance Hashable GetCabalDiagnostics
49+
instance Hashable ParseCabalFile
4450

45-
instance NFData GetCabalDiagnostics
51+
instance NFData ParseCabalFile
52+
53+
type instance RuleResult ParseCabalFields = [Syntax.Field Syntax.Position]
54+
55+
data ParseCabalFields = ParseCabalFields
56+
deriving (Eq, Show, Typeable, Generic)
57+
58+
instance Hashable ParseCabalFields
59+
60+
instance NFData ParseCabalFields
4661

4762
-- | The context a cursor can be in within a cabal file.
4863
--
@@ -61,22 +76,30 @@ data StanzaContext
6176
-- Stanzas have their own fields which differ from top-level fields.
6277
-- Each stanza must be named, such as 'executable exe',
6378
-- except for the main library.
64-
Stanza StanzaType (Maybe StanzaName)
79+
Stanza !StanzaType !(Maybe StanzaName)
6580
deriving (Eq, Show, Read)
6681

82+
instance Pretty StanzaContext where
83+
pretty TopLevel = "TopLevel"
84+
pretty (Stanza t ms) = "Stanza" <+> pretty t <+> (maybe mempty pretty ms)
85+
6786
-- | Keyword context in a cabal file.
6887
--
6988
-- Used to decide whether to suggest values or keywords.
7089
data FieldContext
7190
= -- | Key word context, where a keyword
7291
-- occurs right before the current word
7392
-- to be completed
74-
KeyWord KeyWordName
93+
KeyWord !KeyWordName
7594
| -- | Keyword context where no keyword occurs
7695
-- right before the current word to be completed
7796
None
7897
deriving (Eq, Show, Read)
7998

99+
instance Pretty FieldContext where
100+
pretty (KeyWord kw) = "KeyWord" <+> pretty kw
101+
pretty None = "No Keyword"
102+
80103
type KeyWordName = T.Text
81104

82105
type StanzaName = T.Text
@@ -139,3 +162,12 @@ applyStringNotation (Just LeftSide) compl = compl <> "\""
139162
applyStringNotation Nothing compl
140163
| Just _ <- T.find (== ' ') compl = "\"" <> compl <> "\""
141164
| otherwise = compl
165+
166+
-- | Convert an LSP 'Position' to a 'Syntax.Position'.
167+
--
168+
-- Cabal Positions start their indexing at 1 while LSP starts at 0.
169+
-- This helper makes sure, the translation is done properly.
170+
lspPositionToCabalPosition :: Position -> Syntax.Position
171+
lspPositionToCabalPosition pos = Syntax.Position
172+
(fromIntegral (pos ^. JL.line) + 1)
173+
(fromIntegral (pos ^. JL.character) + 1)

‎plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/Diagnostics.hs

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ module Ide.Plugin.Cabal.Diagnostics
44
( errorDiagnostic
55
, warningDiagnostic
66
, positionFromCabalPosition
7+
, fatalParseErrorDiagnostic
78
-- * Re-exports
89
, FileDiagnostic
910
, Diagnostic(..)
@@ -14,7 +15,7 @@ import qualified Data.Text as T
1415
import Development.IDE (FileDiagnostic,
1516
ShowDiagnostic (ShowDiag))
1617
import Distribution.Fields (showPError, showPWarning)
17-
import qualified Ide.Plugin.Cabal.Parse as Lib
18+
import qualified Distribution.Parsec as Syntax
1819
import Ide.PluginUtils (extendNextLine)
1920
import Language.LSP.Protocol.Types (Diagnostic (..),
2021
DiagnosticSeverity (..),
@@ -23,16 +24,21 @@ import Language.LSP.Protocol.Types (Diagnostic (..),
2324
Range (Range),
2425
fromNormalizedFilePath)
2526

27+
-- | Produce a diagnostic for a fatal Cabal parser error.
28+
fatalParseErrorDiagnostic :: NormalizedFilePath -> T.Text -> FileDiagnostic
29+
fatalParseErrorDiagnostic fp msg =
30+
mkDiag fp "cabal" DiagnosticSeverity_Error (toBeginningOfNextLine Syntax.zeroPos) msg
31+
2632
-- | Produce a diagnostic from a Cabal parser error
27-
errorDiagnostic :: NormalizedFilePath -> Lib.PError -> FileDiagnostic
28-
errorDiagnostic fp err@(Lib.PError pos _) =
33+
errorDiagnostic :: NormalizedFilePath -> Syntax.PError -> FileDiagnostic
34+
errorDiagnostic fp err@(Syntax.PError pos _) =
2935
mkDiag fp "cabal" DiagnosticSeverity_Error (toBeginningOfNextLine pos) msg
3036
where
3137
msg = T.pack $ showPError (fromNormalizedFilePath fp) err
3238

3339
-- | Produce a diagnostic from a Cabal parser warning
34-
warningDiagnostic :: NormalizedFilePath -> Lib.PWarning -> FileDiagnostic
35-
warningDiagnostic fp warning@(Lib.PWarning _ pos _) =
40+
warningDiagnostic :: NormalizedFilePath -> Syntax.PWarning -> FileDiagnostic
41+
warningDiagnostic fp warning@(Syntax.PWarning _ pos _) =
3642
mkDiag fp "cabal" DiagnosticSeverity_Warning (toBeginningOfNextLine pos) msg
3743
where
3844
msg = T.pack $ showPWarning (fromNormalizedFilePath fp) warning
@@ -41,7 +47,7 @@ warningDiagnostic fp warning@(Lib.PWarning _ pos _) =
4147
-- only a single source code 'Lib.Position'.
4248
-- We define the range to be _from_ this position
4349
-- _to_ the first column of the next line.
44-
toBeginningOfNextLine :: Lib.Position -> Range
50+
toBeginningOfNextLine :: Syntax.Position -> Range
4551
toBeginningOfNextLine cabalPos = extendNextLine $ Range pos pos
4652
where
4753
pos = positionFromCabalPosition cabalPos
@@ -53,8 +59,8 @@ toBeginningOfNextLine cabalPos = extendNextLine $ Range pos pos
5359
--
5460
-- >>> positionFromCabalPosition $ Lib.Position 1 1
5561
-- Position 0 0
56-
positionFromCabalPosition :: Lib.Position -> Position
57-
positionFromCabalPosition (Lib.Position line column) = Position (fromIntegral line') (fromIntegral col')
62+
positionFromCabalPosition :: Syntax.Position -> Position
63+
positionFromCabalPosition (Syntax.Position line column) = Position (fromIntegral line') (fromIntegral col')
5864
where
5965
-- LSP is zero-based, Cabal is one-based
6066
line' = line-1
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{-# OPTIONS_GHC -Wno-orphans #-}
2+
module Ide.Plugin.Cabal.Orphans where
3+
import Control.DeepSeq
4+
import Distribution.Fields.Field
5+
import Distribution.Parsec.Position
6+
7+
-- ----------------------------------------------------------------
8+
-- Cabal-syntax orphan instances we need sometimes
9+
-- ----------------------------------------------------------------
10+
11+
instance NFData (Field Position) where
12+
rnf (Field name fieldLines) = rnf name `seq` rnf fieldLines
13+
rnf (Section name sectionArgs fields) = rnf name `seq` rnf sectionArgs `seq` rnf fields
14+
15+
instance NFData (Name Position) where
16+
rnf (Name ann fName) = rnf ann `seq` rnf fName
17+
18+
instance NFData (FieldLine Position) where
19+
rnf (FieldLine ann bs) = rnf ann `seq` rnf bs
20+
21+
instance NFData (SectionArg Position) where
22+
rnf (SecArgName ann bs) = rnf ann `seq` rnf bs
23+
rnf (SecArgStr ann bs) = rnf ann `seq` rnf bs
24+
rnf (SecArgOther ann bs) = rnf ann `seq` rnf bs
Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,7 @@
1+
{-# LANGUAGE OverloadedStrings #-}
12
module Ide.Plugin.Cabal.Parse
23
( parseCabalFileContents
3-
-- * Re-exports
4-
, FilePath
5-
, NonEmpty(..)
6-
, PWarning(..)
7-
, Version
8-
, PError(..)
9-
, Position(..)
10-
, GenericPackageDescription(..)
4+
, readCabalFields
115
) where
126

137
import qualified Data.ByteString as BS
@@ -16,12 +10,31 @@ import Distribution.Fields (PError (..),
1610
PWarning (..))
1711
import Distribution.Fields.ParseResult (runParseResult)
1812
import Distribution.PackageDescription.Parsec (parseGenericPackageDescription)
19-
import Distribution.Parsec.Position (Position (..))
2013
import Distribution.Types.GenericPackageDescription (GenericPackageDescription (..))
2114
import Distribution.Types.Version (Version)
15+
import qualified Ide.Plugin.Cabal.Diagnostics as Diagnostics
16+
17+
import qualified Data.Text as T
18+
import Development.IDE
19+
import qualified Distribution.Fields.Parser as Syntax
20+
import qualified Distribution.Parsec.Position as Syntax
21+
2222

2323
parseCabalFileContents
2424
:: BS.ByteString -- ^ UTF-8 encoded bytestring
2525
-> IO ([PWarning], Either (Maybe Version, NonEmpty PError) GenericPackageDescription)
2626
parseCabalFileContents bs =
2727
pure $ runParseResult (parseGenericPackageDescription bs)
28+
29+
readCabalFields ::
30+
NormalizedFilePath ->
31+
BS.ByteString ->
32+
Either FileDiagnostic [Syntax.Field Syntax.Position]
33+
readCabalFields file contents = do
34+
case Syntax.readFields' contents of
35+
Left parseError ->
36+
Left $ Diagnostics.fatalParseErrorDiagnostic file
37+
$ "Failed to parse cabal file: " <> T.pack (show parseError)
38+
Right (fields, _warnings) -> do
39+
-- we don't want to double report diagnostics, all diagnostics are produced by 'ParseCabalFile'.
40+
Right fields

‎plugins/hls-cabal-plugin/test/Completer.hs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import qualified Data.ByteString as ByteString
99
import Data.Maybe (mapMaybe)
1010
import qualified Data.Text as T
1111
import qualified Development.IDE.Plugin.Completions.Types as Ghcide
12+
import Distribution.PackageDescription (GenericPackageDescription)
1213
import Distribution.PackageDescription.Parsec (parseGenericPackageDescriptionMaybe)
1314
import Ide.Plugin.Cabal.Completion.Completer.FilePath
1415
import Ide.Plugin.Cabal.Completion.Completer.Module
@@ -17,7 +18,6 @@ import Ide.Plugin.Cabal.Completion.Completer.Types (CompleterData (
1718
import Ide.Plugin.Cabal.Completion.Completions
1819
import Ide.Plugin.Cabal.Completion.Types (CabalPrefixInfo (..),
1920
StanzaName)
20-
import Ide.Plugin.Cabal.Parse (GenericPackageDescription)
2121
import qualified Language.LSP.Protocol.Lens as L
2222
import System.FilePath
2323
import Test.Hls

‎plugins/hls-cabal-plugin/test/Context.hs

Lines changed: 134 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
{-# LANGUAGE DisambiguateRecordFields #-}
22
{-# LANGUAGE LambdaCase #-}
33
{-# LANGUAGE OverloadedStrings #-}
4+
{-# LANGUAGE QuasiQuotes #-}
45

56
module Context where
67

7-
import Control.Monad.Trans.Maybe (runMaybeT)
88
import qualified Data.Text as T
9-
import qualified Data.Text.Utf16.Rope.Mixed as Rope
9+
import qualified Data.Text.Encoding as Text
10+
import Development.IDE.Plugin.Completions.Types (PosPrefixInfo (..))
1011
import Ide.Plugin.Cabal
1112
import Ide.Plugin.Cabal.Completion.Completer.Paths
1213
import Ide.Plugin.Cabal.Completion.Completions
1314
import Ide.Plugin.Cabal.Completion.Types (Context,
1415
FieldContext (KeyWord, None),
1516
StanzaContext (Stanza, TopLevel))
17+
import qualified Ide.Plugin.Cabal.Parse as Parse
1618
import Test.Hls
1719
import Utils as T
1820

@@ -22,7 +24,7 @@ cabalPlugin = mkPluginTestDescriptor descriptor "cabal context"
2224
contextTests :: TestTree
2325
contextTests =
2426
testGroup
25-
"Context Tests "
27+
"Context Tests"
2628
[ pathCompletionInfoFromCompletionContextTests
2729
, getContextTests
2830
]
@@ -58,39 +60,39 @@ pathCompletionInfoFromCompletionContextTests =
5860
getContextTests :: TestTree
5961
getContextTests =
6062
testGroup
61-
"Context Tests"
63+
"Context Tests Real"
6264
[ testCase "Empty File - Start" $ do
6365
-- for a completely empty file, the context needs to
6466
-- be top level without a specified keyword
65-
ctx <- callGetContext (Position 0 0) "" [""]
67+
ctx <- callGetContext (Position 0 0) "" ""
6668
ctx @?= (TopLevel, None)
6769
, testCase "Cabal version keyword - no value, no space after :" $ do
6870
-- on a file, where the keyword is already written
6971
-- the context should still be toplevel but the keyword should be recognized
70-
ctx <- callGetContext (Position 0 14) "" ["cabal-version:"]
72+
ctx <- callGetContext (Position 0 14) "" "cabal-version:\n"
7173
ctx @?= (TopLevel, KeyWord "cabal-version:")
7274
, testCase "Cabal version keyword - cursor in keyword" $ do
7375
-- on a file, where the keyword is already written
7476
-- but the cursor is in the middle of the keyword,
7577
-- we are not in a keyword context
76-
ctx <- callGetContext (Position 0 5) "cabal" ["cabal-version:"]
78+
ctx <- callGetContext (Position 0 5) "cabal" "cabal-version:\n"
7779
ctx @?= (TopLevel, None)
7880
, testCase "Cabal version keyword - no value, many spaces" $ do
7981
-- on a file, where the "cabal-version:" keyword is already written
8082
-- the context should still be top level but the keyword should be recognized
81-
ctx <- callGetContext (Position 0 45) "" ["cabal-version:" <> T.replicate 50 " "]
83+
ctx <- callGetContext (Position 0 45) "" ("cabal-version:" <> T.replicate 50 " " <> "\n")
8284
ctx @?= (TopLevel, KeyWord "cabal-version:")
8385
, testCase "Cabal version keyword - keyword partly written" $ do
8486
-- in the first line of the file, if the keyword
8587
-- has not been written completely, the keyword context
8688
-- should still be None
87-
ctx <- callGetContext (Position 0 5) "cabal" ["cabal"]
89+
ctx <- callGetContext (Position 0 5) "cabal" "cabal"
8890
ctx @?= (TopLevel, None)
8991
, testCase "Cabal version keyword - value partly written" $ do
9092
-- in the first line of the file, if the keyword
9193
-- has not been written completely, the keyword context
9294
-- should still be None
93-
ctx <- callGetContext (Position 0 17) "1." ["cabal-version: 1."]
95+
ctx <- callGetContext (Position 0 17) "1." "cabal-version: 1."
9496
ctx @?= (TopLevel, KeyWord "cabal-version:")
9597
, testCase "Inside Stanza - no keyword" $ do
9698
-- on a file, where the library stanza has been defined
@@ -102,14 +104,15 @@ getContextTests =
102104
-- has been defined, the keyword and stanza should be recognized
103105
ctx <- callGetContext (Position 4 21) "" libraryStanzaData
104106
ctx @?= (Stanza "library" Nothing, KeyWord "build-depends:")
105-
, expectFailBecause "While not valid, it is not that important to make the code more complicated for this" $
106-
testCase "Cabal version keyword - no value, next line" $ do
107-
-- if the cabal version keyword has been written but without a value,
108-
-- in the next line we still should be in top level context with no keyword
109-
-- since the cabal version keyword and value pair need to be in the same line
110-
ctx <- callGetContext (Position 1 2) "" ["cabal-version:", ""]
111-
ctx @?= (TopLevel, None)
112-
, testCase "Non-cabal-version keyword - no value, next line indentented position" $ do
107+
, testCase "Cabal version keyword - no value, next line" $ do
108+
-- if the cabal version keyword has been written but without a value,
109+
-- in the next line we still should be in top level context with no keyword
110+
-- since the cabal version keyword and value pair need to be in the same line.
111+
-- However, that's too much work to implement for virtually no benefit, so we
112+
-- test here the status-quo is satisfied.
113+
ctx <- callGetContext (Position 1 2) "" "cabal-version:\n\n"
114+
ctx @?= (TopLevel, KeyWord "cabal-version:")
115+
, testCase "Non-cabal-version keyword - no value, next line indented position" $ do
113116
-- if a keyword, other than the cabal version keyword has been written
114117
-- with no value, in the next line we still should be in top level keyword context
115118
-- of the keyword with no value, since its value may be written in the next line
@@ -153,46 +156,124 @@ getContextTests =
153156
ctx @?= (TopLevel, KeyWord "name:")
154157
, testCase "Named Stanza" $ do
155158
ctx <- callGetContext (Position 2 18) "" executableStanzaData
156-
ctx @?= (Stanza "executable" (Just "exeName"), None)
159+
ctx @?= (TopLevel, None)
160+
, testCase "Multi line, finds context in same line" $ do
161+
ctx <- callGetContext (Position 5 18) "" multiLineOptsData
162+
ctx @?= (Stanza "library" Nothing, KeyWord "build-depends:")
163+
, testCase "Multi line, in the middle of option" $ do
164+
ctx <- callGetContext (Position 6 11) "" multiLineOptsData
165+
ctx @?= (Stanza "library" Nothing, KeyWord "build-depends:")
166+
, testCase "Multi line, finds context in between lines" $ do
167+
ctx <- callGetContext (Position 7 8) "" multiLineOptsData
168+
ctx @?= (Stanza "library" Nothing, KeyWord "build-depends:")
169+
, testCase "Multi line, finds context in between lines, start if line" $ do
170+
ctx <- callGetContext (Position 7 0) "" multiLineOptsData
171+
ctx @?= (TopLevel, None)
172+
, testCase "Multi line, end of option" $ do
173+
ctx <- callGetContext (Position 8 14) "" multiLineOptsData
174+
ctx @?= (Stanza "library" Nothing, KeyWord "build-depends:")
175+
, parameterisedCursorTest "Contexts in large testfile" multiPositionTestData
176+
[ (TopLevel, None)
177+
, (TopLevel, KeyWord "cabal-version:")
178+
, (TopLevel, None)
179+
, (TopLevel, KeyWord "description:")
180+
, (TopLevel, KeyWord "extra-source-files:")
181+
, (TopLevel, None)
182+
-- this might not be what we want, maybe add another Context
183+
, (TopLevel, None)
184+
-- this might not be what we want, maybe add another Context
185+
, (TopLevel, None)
186+
, (Stanza "source-repository" (Just "head"), None)
187+
, (Stanza "source-repository" (Just "head"), KeyWord "type:")
188+
, (Stanza "source-repository" (Just "head"), KeyWord "type:")
189+
, (Stanza "source-repository" (Just "head"), KeyWord "type:")
190+
, (Stanza "source-repository" (Just "head"), None)
191+
]
192+
$ \fileContent posPrefInfo ->
193+
callGetContext (cursorPos posPrefInfo) (prefixText posPrefInfo) fileContent
157194
]
158195
where
159-
callGetContext :: Position -> T.Text -> [T.Text] -> IO Context
196+
callGetContext :: Position -> T.Text -> T.Text -> IO Context
160197
callGetContext pos pref ls = do
161-
runMaybeT (getContext mempty (simpleCabalPrefixInfoFromPos pos pref) (Rope.fromText $ T.unlines ls))
162-
>>= \case
163-
Nothing -> assertFailure "Context must be found"
164-
Just ctx -> pure ctx
198+
case Parse.readCabalFields "not-real" (Text.encodeUtf8 ls) of
199+
Left err -> fail $ show err
200+
Right fields -> do
201+
getContext mempty (simpleCabalPrefixInfoFromPos pos pref) fields
165202

166203
-- ------------------------------------------------------------------------
167204
-- Test Data
168205
-- ------------------------------------------------------------------------
169206

170-
libraryStanzaData :: [T.Text]
171-
libraryStanzaData =
172-
[ "cabal-version: 3.0"
173-
, "name: simple-cabal"
174-
, "library "
175-
, " default-language: Haskell98"
176-
, " build-depends: "
177-
, " "
178-
, "ma "
179-
]
180-
181-
executableStanzaData :: [T.Text]
182-
executableStanzaData =
183-
[ "cabal-version: 3.0"
184-
, "name: simple-cabal"
185-
, "executable exeName"
186-
, " default-language: Haskell2010"
187-
, " hs-source-dirs: test/preprocessor"
188-
]
189-
190-
topLevelData :: [T.Text]
191-
topLevelData =
192-
[ "cabal-version: 3.0"
193-
, "name:"
194-
, ""
195-
, ""
196-
, ""
197-
, " eee"
198-
]
207+
libraryStanzaData :: T.Text
208+
libraryStanzaData = [trimming|
209+
cabal-version: 3.0
210+
name: simple-cabal
211+
library
212+
default-language: Haskell98
213+
build-depends:
214+
215+
ma
216+
|]
217+
218+
executableStanzaData :: T.Text
219+
executableStanzaData = [trimming|
220+
cabal-version: 3.0
221+
name: simple-cabal
222+
executable exeName
223+
default-language: Haskell2010
224+
hs-source-dirs: test/preprocessor
225+
|]
226+
227+
topLevelData :: T.Text
228+
topLevelData = [trimming|
229+
cabal-version: 3.0
230+
name:
231+
232+
233+
234+
eee
235+
|]
236+
237+
multiLineOptsData :: T.Text
238+
multiLineOptsData = [trimming|
239+
cabal-version: 3.0
240+
name:
241+
242+
243+
library
244+
build-depends:
245+
base,
246+
247+
text ,
248+
|]
249+
250+
multiPositionTestData :: T.Text
251+
multiPositionTestData = [trimming|
252+
cabal-version: 3.4
253+
^ ^
254+
category: Development
255+
^
256+
name: haskell-language-server
257+
description:
258+
Please see the README on GitHub at <https://github.com/haskell/haskell-language-server#readme>
259+
^
260+
extra-source-files:
261+
README.md
262+
ChangeLog.md
263+
test/testdata/**/*.project
264+
test/testdata/**/*.cabal
265+
test/testdata/**/*.yaml
266+
test/testdata/**/*.hs
267+
test/testdata/**/*.json
268+
^
269+
-- These globs should only match test/testdata
270+
plugins/**/*.project
271+
272+
source-repository head
273+
^ ^ ^
274+
type: git
275+
^ ^ ^ ^
276+
location: https://github.com/haskell/haskell-language-server
277+
278+
^
279+
|]

0 commit comments

Comments
 (0)
Please sign in to comment.