|
| 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