Skip to content

Commit 1675266

Browse files
committed
Add Code Action for adding a module to the cabal file
For diagnostics complaining about the current module being unknown, we now offer code actions to add the module to any possible field in the responsible cabal file. Additionally, refactor the cabal-plugin into smaller modules and refactor the add-package feature to have some shared functions to be used for both add-package and add-module.
1 parent 8cac5fb commit 1675266

File tree

19 files changed

+1344
-690
lines changed

19 files changed

+1344
-690
lines changed

haskell-language-server.cabal

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -254,8 +254,13 @@ library hls-cabal-plugin
254254
Ide.Plugin.Cabal.Completion.Types
255255
Ide.Plugin.Cabal.Definition
256256
Ide.Plugin.Cabal.FieldSuggest
257+
Ide.Plugin.Cabal.Files
258+
Ide.Plugin.Cabal.OfInterest
257259
Ide.Plugin.Cabal.LicenseSuggest
258-
Ide.Plugin.Cabal.CabalAdd
260+
Ide.Plugin.Cabal.Rules
261+
Ide.Plugin.Cabal.CabalAdd.Command
262+
Ide.Plugin.Cabal.CabalAdd.CodeAction
263+
Ide.Plugin.Cabal.CabalAdd.Types
259264
Ide.Plugin.Cabal.Orphans
260265
Ide.Plugin.Cabal.Outline
261266
Ide.Plugin.Cabal.Parse
@@ -276,14 +281,14 @@ library hls-cabal-plugin
276281
, lens
277282
, lsp ^>=2.7
278283
, lsp-types ^>=2.3
284+
, mtl
279285
, regex-tdfa ^>=1.3.1
280286
, text
281287
, text-rope
282288
, transformers
283289
, unordered-containers >=0.2.10.0
284290
, containers
285-
, cabal-add
286-
, process
291+
, cabal-add ^>=0.2
287292
, aeson
288293
, Cabal
289294
, pretty
@@ -315,7 +320,6 @@ test-suite hls-cabal-plugin-tests
315320
, lens
316321
, lsp-types
317322
, text
318-
, hls-plugin-api
319323

320324
-----------------------------
321325
-- class plugin

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

Lines changed: 164 additions & 325 deletions
Large diffs are not rendered by default.

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

Lines changed: 0 additions & 326 deletions
This file was deleted.
Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
{-# LANGUAGE AllowAmbiguousTypes #-}
2+
{-# LANGUAGE DeriveAnyClass #-}
3+
{-# LANGUAGE DerivingStrategies #-}
4+
{-# LANGUAGE ExplicitNamespaces #-}
5+
{-# LANGUAGE LambdaCase #-}
6+
{-# LANGUAGE OverloadedStrings #-}
7+
{-# LANGUAGE PartialTypeSignatures #-}
8+
{-# LANGUAGE RecordWildCards #-}
9+
10+
module Ide.Plugin.Cabal.CabalAdd.CodeAction where
11+
12+
import Control.Monad.IO.Class (MonadIO, liftIO)
13+
import Control.Monad.Trans.Except
14+
import Data.Aeson.Types (toJSON)
15+
import Data.Foldable (asum)
16+
import qualified Data.Map as Map
17+
import Data.Maybe (fromMaybe,
18+
mapMaybe)
19+
import qualified Data.Text as T
20+
import Development.IDE.Core.PluginUtils (uriToFilePathE)
21+
import Development.IDE.Types.Location (Uri)
22+
import Distribution.PackageDescription
23+
import Distribution.PackageDescription.Configuration (flattenPackageDescription)
24+
import qualified Distribution.Pretty as CabalPretty
25+
import Distribution.Simple.BuildTarget (BuildTarget,
26+
buildTargetComponentName,
27+
readBuildTargets)
28+
import Distribution.Utils.Path (getSymbolicPath)
29+
import Distribution.Verbosity (silent,
30+
verboseNoStderr)
31+
import Ide.Logger
32+
import Ide.Plugin.Cabal.CabalAdd.Types
33+
import Ide.Plugin.Cabal.Completion.Completer.Module (fpToExposedModulePath)
34+
import Ide.Plugin.Cabal.Orphans ()
35+
import Ide.Plugin.Error
36+
import Ide.PluginUtils (mkLspCommand)
37+
import Ide.Types (CommandId (CommandId),
38+
PluginId)
39+
40+
import Control.Lens ((^.))
41+
import qualified Language.LSP.Protocol.Lens as JL
42+
import Language.LSP.Protocol.Types (CodeActionKind (..),
43+
VersionedTextDocumentIdentifier,
44+
filePathToUri)
45+
import qualified Language.LSP.Protocol.Types as J
46+
import System.FilePath
47+
import Text.PrettyPrint (render)
48+
import Text.Regex.TDFA
49+
50+
--------------------------------------------
51+
-- Add Module
52+
--------------------------------------------
53+
54+
{- | Takes a path to a cabal file, a module path in exposed module syntax
55+
and the contents of the cabal file and generates all possible
56+
code actions for inserting the module into the cabal file
57+
with the given contents.
58+
-}
59+
collectModuleInsertionOptions ::
60+
(MonadIO m) =>
61+
Recorder (WithPriority Log) ->
62+
PluginId ->
63+
VersionedTextDocumentIdentifier ->
64+
J.Diagnostic ->
65+
-- | The file path of the cabal file to insert the new module into
66+
FilePath ->
67+
-- | The generic package description of the cabal file to insert the new module into.
68+
GenericPackageDescription ->
69+
-- | The URI of the unknown haskell file/new module to insert into the cabal file.
70+
Uri ->
71+
ExceptT PluginError m [J.CodeAction]
72+
collectModuleInsertionOptions _ plId txtDocIdentifier diag cabalFilePath gpd haskellFilePathURI = do
73+
haskellFilePath <- uriToFilePathE haskellFilePathURI
74+
let configs = concatMap (mkModuleInsertionConfig txtDocIdentifier cabalFilePath haskellFilePath) (makeStanzaItems gpd)
75+
pure $ map (mkCodeActionForModulePath plId diag) configs
76+
where
77+
makeStanzaItems :: GenericPackageDescription -> [StanzaItem]
78+
makeStanzaItems gpd =
79+
mainLibItem pd
80+
++ libItems pd
81+
++ executableItems pd
82+
++ testSuiteItems pd
83+
++ benchmarkItems pd
84+
where
85+
pd = flattenPackageDescription gpd
86+
87+
{- | Takes a buildInfo of a cabal file component as defined in the generic package description,
88+
and translates it to filepaths of the component's hsSourceDirs,
89+
to be processed for adding modules to exposed-, or other-modules fields in a cabal file.
90+
-}
91+
buildInfoToHsSourceDirs :: BuildInfo -> [FilePath]
92+
buildInfoToHsSourceDirs buildInfo = map getSymbolicPath hsSourceDirs'
93+
where
94+
hsSourceDirs' = hsSourceDirs buildInfo
95+
96+
{- | Takes the path to the cabal file to insert the module into,
97+
the module path to be inserted, and a stanza representation.
98+
99+
Returns a list of module insertion configs, where each config
100+
represents a possible place to insert the module.
101+
-}
102+
mkModuleInsertionConfig :: VersionedTextDocumentIdentifier -> FilePath -> FilePath -> StanzaItem -> [ModuleInsertionConfig]
103+
mkModuleInsertionConfig txtDocIdentifier cabalFilePath haskellFilePath (StanzaItem{..}) = do
104+
case mkRelativeModulePathM siHsSourceDirs cabalFilePath haskellFilePath of
105+
Just processedModPath ->
106+
[modInsertItem processedModPath "other-modules"]
107+
++ [modInsertItem processedModPath "exposed-modules" | CLibName _ <- [siComponent]]
108+
_ -> []
109+
where
110+
modInsertItem :: T.Text -> T.Text -> ModuleInsertionConfig
111+
modInsertItem modPath label =
112+
ModuleInsertionConfig
113+
{ targetFile = cabalFilePath
114+
, moduleToInsert = modPath
115+
, modVerTxtDocId = txtDocIdentifier
116+
, insertionStanza = siComponent
117+
, insertionLabel = label
118+
}
119+
120+
mkCodeActionForModulePath :: PluginId -> J.Diagnostic -> ModuleInsertionConfig -> J.CodeAction
121+
mkCodeActionForModulePath plId diag insertionConfig =
122+
J.CodeAction
123+
{ _title = "Add to " <> label <> " as " <> fieldDescription
124+
, _kind = Just CodeActionKind_Refactor
125+
, _diagnostics = Just [diag]
126+
, _isPreferred = Nothing
127+
, _disabled = Nothing
128+
, _edit = Just workSpaceEdit
129+
, _command = Just command
130+
, _data_ = Nothing
131+
}
132+
where
133+
fieldName = insertionLabel insertionConfig
134+
command = mkLspCommand plId (CommandId cabalAddModuleCommandId) "Add missing module" (Just [toJSON insertionConfig])
135+
fieldDescription = fromMaybe fieldName $ T.stripSuffix "s:" fieldName
136+
cabalFilePath = targetFile insertionConfig
137+
label = T.pack $ CabalPretty.prettyShow $ insertionStanza insertionConfig
138+
workSpaceEdit =
139+
J.WorkspaceEdit
140+
{ J._changes =
141+
Just $
142+
Map.singleton
143+
(filePathToUri cabalFilePath)
144+
[] -- makeDiffTextEdit on cabalAdd result
145+
, J._documentChanges = Nothing
146+
, J._changeAnnotations = Nothing
147+
}
148+
149+
{- | Takes a list of source subdirectories, a cabal source path and a haskell filepath
150+
and returns a path to the module in exposed module syntax.
151+
The path will be relative to one of the subdirectories, in case the module is contained within one of them.
152+
-}
153+
mkRelativeModulePathM :: [FilePath] -> FilePath -> FilePath -> Maybe T.Text
154+
mkRelativeModulePathM hsSourceDirs cabalSrcPath' haskellFilePath =
155+
asum $
156+
map
157+
( \srcDir -> do
158+
let relMP = makeRelative (normalise (cabalSrcPath </> srcDir)) haskellFilePath
159+
if relMP == haskellFilePath then Nothing else Just $ fpToExposedModulePath cabalSrcPath relMP
160+
)
161+
hsSourceDirs
162+
where
163+
cabalSrcPath = takeDirectory cabalSrcPath'
164+
165+
isUnknownModuleDiagnostic :: J.Diagnostic -> Bool
166+
isUnknownModuleDiagnostic diag = (msg =~ regex)
167+
where
168+
msg :: T.Text
169+
msg = diag ^. JL.message
170+
regex :: T.Text
171+
regex = "Loading the module [\8216'][^\8217']*[\8217'] failed."
172+
173+
--------------------------
174+
-- Below are several utility functions which create a StanzaItem for each of the possible Stanzas,
175+
-- these all have specific constructors we need to match, so we can't generalise this process well.
176+
--------------------------
177+
178+
benchmarkItems :: PackageDescription -> [StanzaItem]
179+
benchmarkItems pd =
180+
map
181+
( \benchmark ->
182+
StanzaItem
183+
{ siComponent = CBenchName $ benchmarkName benchmark
184+
, siHsSourceDirs = buildInfoToHsSourceDirs $ benchmarkBuildInfo benchmark
185+
}
186+
)
187+
(benchmarks pd)
188+
189+
testSuiteItems :: PackageDescription -> [StanzaItem]
190+
testSuiteItems pd =
191+
map
192+
( \testSuite ->
193+
StanzaItem
194+
{ siComponent = CTestName $ testName testSuite
195+
, siHsSourceDirs = buildInfoToHsSourceDirs $ testBuildInfo testSuite
196+
}
197+
)
198+
(testSuites pd)
199+
200+
executableItems :: PackageDescription -> [StanzaItem]
201+
executableItems pd =
202+
map
203+
( \executable ->
204+
StanzaItem
205+
{ siComponent = CExeName $ exeName executable
206+
, siHsSourceDirs = buildInfoToHsSourceDirs $ buildInfo executable
207+
}
208+
)
209+
(executables pd)
210+
211+
libItems :: PackageDescription -> [StanzaItem]
212+
libItems pd =
213+
mapMaybe
214+
( \subLib ->
215+
case libName subLib of
216+
LSubLibName compName ->
217+
Just
218+
StanzaItem
219+
{ siComponent = CLibName $ LSubLibName compName
220+
, siHsSourceDirs = buildInfoToHsSourceDirs $ libBuildInfo subLib
221+
}
222+
_ -> Nothing
223+
)
224+
(subLibraries pd)
225+
226+
mainLibItem :: PackageDescription -> [StanzaItem]
227+
mainLibItem pd =
228+
case library pd of
229+
Just lib ->
230+
[ StanzaItem
231+
{ siComponent = CLibName LMainLibName
232+
, siHsSourceDirs = buildInfoToHsSourceDirs $ libBuildInfo lib
233+
}
234+
]
235+
Nothing -> []
236+
237+
--------------------------------------------
238+
-- Add Package
239+
--------------------------------------------
240+
241+
{- | Creates a code action that calls the `cabalAddCommand`,
242+
using dependency-version suggestion pairs as input.
243+
244+
Returns disabled action if no cabal files given.
245+
246+
Takes haskell and cabal file paths to create a relative path
247+
to the haskell file, which is used to get a `BuildTarget`.
248+
-}
249+
addDependencySuggestCodeAction ::
250+
PluginId ->
251+
-- | Cabal's versioned text identifier
252+
VersionedTextDocumentIdentifier ->
253+
-- | A dependency-version suggestion pairs
254+
[(T.Text, T.Text)] ->
255+
-- | Path to the haskell file (source of diagnostics)
256+
FilePath ->
257+
-- | Path to the cabal file (that will be edited)
258+
FilePath ->
259+
GenericPackageDescription ->
260+
IO [J.CodeAction]
261+
addDependencySuggestCodeAction plId verTxtDocId suggestions haskellFilePath cabalFilePath gpd = do
262+
buildTargets <- liftIO $ getBuildTargets gpd cabalFilePath haskellFilePath
263+
case buildTargets of
264+
-- If there are no build targets found, run the `cabal-add` command with default behaviour
265+
[] -> pure $ mkCodeAction cabalFilePath Nothing <$> suggestions
266+
-- Otherwise provide actions for all found targets
267+
targets ->
268+
pure $
269+
concat
270+
[ mkCodeAction cabalFilePath (Just $ buildTargetToStringRepr target)
271+
<$> suggestions
272+
| target <- targets
273+
]
274+
where
275+
{- | Note the use of the `pretty` function.
276+
It converts the `BuildTarget` to an acceptable string representation.
277+
It will be used as the input for `cabal-add`'s `executeConfig`.
278+
-}
279+
buildTargetToStringRepr target = render $ CabalPretty.pretty $ buildTargetComponentName target
280+
281+
{- | Finds the build targets that are used in `cabal-add`.
282+
Note the unorthodox usage of `readBuildTargets`:
283+
If the relative path to the haskell file is provided,
284+
`readBuildTargets` will return the build targets, this
285+
module is mentioned in (either exposed-modules or other-modules).
286+
-}
287+
getBuildTargets :: GenericPackageDescription -> FilePath -> FilePath -> IO [BuildTarget]
288+
getBuildTargets gpd cabalFilePath haskellFilePath = do
289+
let haskellFileRelativePath = makeRelative (dropFileName cabalFilePath) haskellFilePath
290+
readBuildTargets (verboseNoStderr silent) (flattenPackageDescription gpd) [haskellFileRelativePath]
291+
292+
mkCodeAction :: FilePath -> Maybe String -> (T.Text, T.Text) -> J.CodeAction
293+
mkCodeAction cabalFilePath target (suggestedDep, suggestedVersion) =
294+
let
295+
versionTitle = if T.null suggestedVersion then T.empty else "-" <> suggestedVersion
296+
targetTitle = case target of
297+
Nothing -> T.empty
298+
Just t -> " at " <> T.pack t
299+
title = "Add dependency " <> suggestedDep <> versionTitle <> targetTitle
300+
version = if T.null suggestedVersion then Nothing else Just suggestedVersion
301+
302+
params =
303+
CabalAddPackageCommandParams
304+
{ pkgCabalPath = cabalFilePath
305+
, pkgVerTxtDocId = verTxtDocId
306+
, pkgBuildTarget = target
307+
, pkgDependency = suggestedDep
308+
, pkgVersion = version
309+
}
310+
command = mkLspCommand plId (CommandId cabalAddPackageCommandId) "Add dependency" (Just [toJSON params])
311+
in
312+
J.CodeAction title (Just CodeActionKind_QuickFix) (Just []) Nothing Nothing Nothing (Just command) Nothing
313+
314+
{- | Gives a mentioned number of @(dependency, version)@ pairs
315+
found in the "hidden package" diagnostic message.
316+
317+
For example, if a ghc error looks like this:
318+
319+
> "Could not load module ‘Data.List.Split’
320+
> It is a member of the hidden package ‘split-0.2.5’.
321+
> Perhaps you need to add ‘split’ to the build-depends in your .cabal file."
322+
323+
or this if PackageImports extension is used:
324+
325+
> "Could not find module ‘Data.List.Split’
326+
> Perhaps you meant
327+
> Data.List.Split (needs flag -package-id split-0.2.5)"
328+
329+
It extracts mentioned package names and version numbers.
330+
In this example, it will be @[("split", "0.2.5")]@
331+
332+
Also supports messages without a version.
333+
334+
> "Perhaps you need to add ‘split’ to the build-depends in your .cabal file."
335+
336+
Will turn into @[("split", "")]@
337+
-}
338+
hiddenPackageSuggestion :: J.Diagnostic -> [(T.Text, T.Text)]
339+
hiddenPackageSuggestion diag = getMatch (msg =~ regex)
340+
where
341+
msg :: T.Text
342+
msg = diag ^. JL.message
343+
regex :: T.Text
344+
regex =
345+
let regex' = "([a-zA-Z0-9-]*[a-zA-Z0-9])(-([0-9\\.]*))?"
346+
in "It is a member of the hidden package [\8216']"
347+
<> regex'
348+
<> "[\8217']"
349+
<> "|"
350+
<> "needs flag -package-id "
351+
<> regex'
352+
-- Have to do this matching because `Regex.TDFA` doesn't(?) support
353+
-- not-capturing groups like (?:message)
354+
getMatch :: (T.Text, T.Text, T.Text, [T.Text]) -> [(T.Text, T.Text)]
355+
getMatch (_, _, _, []) = []
356+
getMatch (_, _, _, [dependency, _, cleanVersion, "", "", ""]) = [(dependency, cleanVersion)]
357+
getMatch (_, _, _, ["", "", "", dependency, _, cleanVersion]) = [(dependency, cleanVersion)]
358+
getMatch (_, _, _, _) = []

0 commit comments

Comments
 (0)