diff --git a/cabal.project b/cabal.project index dc7887ee7a..2f72e887f8 100644 --- a/cabal.project +++ b/cabal.project @@ -7,6 +7,7 @@ packages: ./hls-plugin-api ./hls-test-utils + index-state: 2024-06-29T00:00:00Z tests: True diff --git a/haskell-language-server.cabal b/haskell-language-server.cabal index c79d714fc3..e89f22ad8a 100644 --- a/haskell-language-server.cabal +++ b/haskell-language-server.cabal @@ -244,6 +244,7 @@ library hls-cabal-plugin Ide.Plugin.Cabal.Completion.Types Ide.Plugin.Cabal.FieldSuggest Ide.Plugin.Cabal.LicenseSuggest + Ide.Plugin.Cabal.CabalAdd Ide.Plugin.Cabal.Orphans Ide.Plugin.Cabal.Outline Ide.Plugin.Cabal.Parse @@ -270,6 +271,12 @@ library hls-cabal-plugin , transformers , unordered-containers >=0.2.10.0 , containers + , cabal-add + , process + , aeson + , Cabal + , pretty + hs-source-dirs: plugins/hls-cabal-plugin/src test-suite hls-cabal-plugin-tests @@ -284,6 +291,7 @@ test-suite hls-cabal-plugin-tests Context Utils Outline + CabalAdd build-depends: , base , bytestring @@ -296,6 +304,7 @@ test-suite hls-cabal-plugin-tests , lens , lsp-types , text + , hls-plugin-api ----------------------------- -- class plugin diff --git a/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal.hs b/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal.hs index 317f48bb3a..03e8fbfdff 100644 --- a/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal.hs +++ b/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal.hs @@ -4,7 +4,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TypeFamilies #-} -module Ide.Plugin.Cabal (descriptor, Log (..)) where +module Ide.Plugin.Cabal (descriptor, haskellInteractionDescriptor, Log (..)) where import Control.Concurrent.Strict import Control.DeepSeq @@ -53,6 +53,9 @@ import qualified Language.LSP.Protocol.Message as LSP import Language.LSP.Protocol.Types import qualified Language.LSP.VFS as VFS +import qualified Data.Text () +import qualified Ide.Plugin.Cabal.CabalAdd as CabalAdd + data Log = LogModificationTime NormalizedFilePath FileVersion | LogShake Shake.Log @@ -63,6 +66,7 @@ data Log | LogFOI (HashMap NormalizedFilePath FileOfInterestStatus) | LogCompletionContext Types.Context Position | LogCompletions Types.Log + | LogCabalAdd CabalAdd.Log deriving (Show) instance Pretty Log where @@ -86,6 +90,25 @@ instance Pretty Log where <+> "for cursor position:" <+> pretty position LogCompletions logs -> pretty logs + LogCabalAdd logs -> pretty logs + +-- | Some actions with cabal files originate from haskell files. +-- This descriptor allows to hook into the diagnostics of haskell source files, and +-- allows us to provide code actions and commands that interact with `.cabal` files. +haskellInteractionDescriptor :: Recorder (WithPriority Log) -> PluginId -> PluginDescriptor IdeState +haskellInteractionDescriptor recorder plId = + (defaultPluginDescriptor plId "Provides the cabal-add code action in haskell files") + { pluginHandlers = + mconcat + [ mkPluginHandler LSP.SMethod_TextDocumentCodeAction cabalAddCodeAction + ] + , pluginCommands = [PluginCommand CabalAdd.cabalAddCommand "add a dependency to a cabal file" (CabalAdd.command cabalAddRecorder)] + , pluginRules = pure () + , pluginNotificationHandlers = mempty + } + where + cabalAddRecorder = cmapWithPrio LogCabalAdd recorder + descriptor :: Recorder (WithPriority Log) -> PluginId -> PluginDescriptor IdeState descriptor recorder plId = @@ -309,6 +332,32 @@ gotoDefinition ideState _ msgParam = do isSectionArgName name (Syntax.Section _ sectionArgName _) = name == CabalFields.onelineSectionArgs sectionArgName isSectionArgName _ _ = False +cabalAddCodeAction :: PluginMethodHandler IdeState 'LSP.Method_TextDocumentCodeAction +cabalAddCodeAction state plId (CodeActionParams _ _ (TextDocumentIdentifier uri) _ CodeActionContext{_diagnostics=diags}) = do + maxCompls <- fmap maxCompletions . liftIO $ runAction "cabal.cabal-add" state getClientConfigAction + let suggestions = take maxCompls $ concatMap CabalAdd.hiddenPackageSuggestion diags + case suggestions of + [] -> pure $ InL [] + _ -> + case uriToFilePath uri of + Nothing -> pure $ InL [] + Just haskellFilePath -> do + mbCabalFile <- liftIO $ CabalAdd.findResponsibleCabalFile haskellFilePath + case mbCabalFile of + Nothing -> pure $ InL [] + Just cabalFilePath -> do + verTxtDocId <- lift $ pluginGetVersionedTextDoc $ TextDocumentIdentifier (filePathToUri cabalFilePath) + mbGPD <- liftIO $ runAction "cabal.cabal-add" state $ useWithStale ParseCabalFile $ toNormalizedFilePath cabalFilePath + case mbGPD of + Nothing -> pure $ InL [] + Just (gpd, _) -> do + actions <- liftIO $ CabalAdd.addDependencySuggestCodeAction plId verTxtDocId + suggestions + haskellFilePath cabalFilePath + gpd + pure $ InL $ fmap InR actions + + -- ---------------------------------------------------------------- -- Cabal file of Interest rules and global variable -- ---------------------------------------------------------------- diff --git a/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/CabalAdd.hs b/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/CabalAdd.hs new file mode 100644 index 0000000000..e60d06db78 --- /dev/null +++ b/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/CabalAdd.hs @@ -0,0 +1,308 @@ +{-# LANGUAGE AllowAmbiguousTypes #-} +{-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DerivingStrategies #-} +{-# LANGUAGE ExplicitNamespaces #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PartialTypeSignatures #-} +{-# LANGUAGE RecordWildCards #-} + +module Ide.Plugin.Cabal.CabalAdd +( findResponsibleCabalFile + , addDependencySuggestCodeAction + , hiddenPackageSuggestion + , cabalAddCommand + , command + , Log +) +where + +import Control.Monad (filterM, void) +import Control.Monad.IO.Class (MonadIO, liftIO) +import Control.Monad.Trans.Class (lift) +import Control.Monad.Trans.Except +import Data.Aeson.Types (FromJSON, + ToJSON, toJSON) +import Data.ByteString (ByteString) +import qualified Data.ByteString.Char8 as B +import Data.List.NonEmpty (NonEmpty (..), + fromList) +import Data.String (IsString) +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import qualified Data.Text.Encoding as T +import Development.IDE (IdeState, + useWithStale) +import Development.IDE.Core.Rules (runAction) +import Development.IDE.Core.RuleTypes (GetFileContents (..)) +import Distribution.Client.Add as Add +import Distribution.Compat.Prelude (Generic) +import Distribution.PackageDescription (GenericPackageDescription, + packageDescription, + specVersion) +import Distribution.PackageDescription.Configuration (flattenPackageDescription) +import Distribution.PackageDescription.Quirks (patchQuirks) +import qualified Distribution.Pretty as Pretty +import Distribution.Simple.BuildTarget (BuildTarget, + buildTargetComponentName, + readBuildTargets) +import Distribution.Simple.Utils (safeHead) +import Distribution.Verbosity (silent, + verboseNoStderr) +import Ide.Logger +import Ide.Plugin.Cabal.Completion.Types (ParseCabalFields (..), + ParseCabalFile (..)) +import Ide.Plugin.Cabal.Orphans () +import Ide.Plugin.Error +import Ide.PluginUtils (WithDeletions (SkipDeletions), + diffText, + mkLspCommand) +import Ide.Types (CommandFunction, + CommandId (CommandId), + PluginId, + pluginGetClientCapabilities, + pluginSendRequest) +import Language.LSP.Protocol.Message (SMethod (SMethod_WorkspaceApplyEdit)) +import Language.LSP.Protocol.Types (ApplyWorkspaceEditParams (ApplyWorkspaceEditParams), + ClientCapabilities, + CodeAction (CodeAction), + CodeActionKind (CodeActionKind_QuickFix), + Diagnostic (..), + Null (Null), + VersionedTextDocumentIdentifier, + WorkspaceEdit, + toNormalizedFilePath, + type (|?) (InR)) +import System.Directory (doesFileExist, + listDirectory) +import System.FilePath (dropFileName, + makeRelative, + splitPath, + takeExtension, + ()) +import Text.PrettyPrint (render) +import Text.Regex.TDFA + +data Log + = LogFoundResponsibleCabalFile FilePath + | LogCalledCabalAddCommand CabalAddCommandParams + | LogCreatedEdit WorkspaceEdit + | LogExecutedCommand + deriving (Show) + +instance Pretty Log where + pretty = \case + LogFoundResponsibleCabalFile fp -> "Located the responsible cabal file at " <+> pretty fp + LogCalledCabalAddCommand params -> "Called CabalAdd command with:\n" <+> pretty params + LogCreatedEdit edit -> "Created inplace edit:\n" <+> pretty edit + LogExecutedCommand -> "Executed CabalAdd command" + +cabalAddCommand :: IsString p => p +cabalAddCommand = "cabalAdd" + +data CabalAddCommandParams = + CabalAddCommandParams { cabalPath :: FilePath + , verTxtDocId :: VersionedTextDocumentIdentifier + , buildTarget :: Maybe String + , dependency :: T.Text + , version :: Maybe T.Text + } + deriving (Generic, Show) + deriving anyclass (FromJSON, ToJSON) + +instance Pretty CabalAddCommandParams where + pretty CabalAddCommandParams{..} = + "CabalAdd parameters:" <+> vcat + [ "cabal path:" <+> pretty cabalPath + , "target:" <+> pretty buildTarget + , "dependendency:" <+> pretty dependency + , "version:" <+> pretty version + ] + +-- | Creates a code action that calls the `cabalAddCommand`, +-- using dependency-version suggestion pairs as input. +-- +-- Returns disabled action if no cabal files given. +-- +-- Takes haskell file and cabal file paths to create a relative path +-- to the haskell file, which is used to get a `BuildTarget`. +-- +-- In current implementation the dependency is being added to the main found +-- build target, but if there will be a way to get all build targets from a file +-- it will be possible to support addition to a build target of choice. +addDependencySuggestCodeAction + :: PluginId + -> VersionedTextDocumentIdentifier -- ^ Cabal's versioned text identifier + -> [(T.Text, T.Text)] -- ^ A dependency-version suggestion pairs + -> FilePath -- ^ Path to the haskell file (source of diagnostics) + -> FilePath -- ^ Path to the cabal file (that will be edited) + -> GenericPackageDescription + -> IO [CodeAction] +addDependencySuggestCodeAction plId verTxtDocId suggestions haskellFilePath cabalFilePath gpd = do + buildTargets <- liftIO $ getBuildTargets gpd cabalFilePath haskellFilePath + case buildTargets of + -- If there are no build targets found, run `cabal-add` command with default behaviour + [] -> pure $ mkCodeAction cabalFilePath Nothing <$> suggestions + -- Otherwise provide actions for all found targets + targets -> pure $ concat [mkCodeAction cabalFilePath (Just $ buildTargetToStringRepr target) <$> + suggestions | target <- targets] + where + -- | Note the use of `pretty` function. + -- It converts the `BuildTarget` to an acceptable string representation. + -- It will be used in as the input for `cabal-add`'s `executeConfig`. + buildTargetToStringRepr target = render $ Pretty.pretty $ buildTargetComponentName target + + -- | Gives the build targets that are used in the `CabalAdd`. + -- Note the unorthodox usage of `readBuildTargets`: + -- If the relative path to the haskell file is provided, + -- the `readBuildTargets` will return a main build target. + -- This behaviour is acceptable for now, but changing to a way of getting + -- all build targets in a file is advised. + getBuildTargets :: GenericPackageDescription -> FilePath -> FilePath -> IO [BuildTarget] + getBuildTargets gpd cabalFilePath haskellFilePath = do + let haskellFileRelativePath = makeRelative (dropFileName cabalFilePath) haskellFilePath + readBuildTargets (verboseNoStderr silent) (flattenPackageDescription gpd) [haskellFileRelativePath] + + mkCodeAction :: FilePath -> Maybe String -> (T.Text, T.Text) -> CodeAction + mkCodeAction cabalFilePath target (suggestedDep, suggestedVersion) = + let + versionTitle = if T.null suggestedVersion then T.empty else " version " <> suggestedVersion + targetTitle = case target of + Nothing -> T.empty + Just t -> " target " <> T.pack t + title = "Add dependency " <> suggestedDep <> versionTitle <> targetTitle + version = if T.null suggestedVersion then Nothing else Just suggestedVersion + + params = CabalAddCommandParams {cabalPath = cabalFilePath + , verTxtDocId = verTxtDocId + , buildTarget = target + , dependency = suggestedDep + , version=version} + command = mkLspCommand plId (CommandId cabalAddCommand) "Add missing dependency" (Just [toJSON params]) + in CodeAction title (Just CodeActionKind_QuickFix) (Just []) Nothing Nothing Nothing (Just command) Nothing + +-- | Gives a mentioned number of @(dependency, version)@ pairs +-- found in the "hidden package" diagnostic message. +-- +-- For example, if a ghc error looks like this: +-- +-- > "Could not load module ‘Data.List.Split’ +-- > It is a member of the hidden package ‘split-0.2.5’. +-- > Perhaps you need to add ‘split’ to the build-depends in your .cabal file." +-- +-- It extracts mentioned package names and version numbers. +-- In this example, it will be @[("split", "0.2.5")]@ +-- +-- Also supports messages without a version. +-- +-- > "Perhaps you need to add ‘split’ to the build-depends in your .cabal file." +-- +-- Will turn into @[("split", "")]@ +hiddenPackageSuggestion :: Diagnostic -> [(T.Text, T.Text)] +hiddenPackageSuggestion diag = getMatch (msg =~ regex) + where + msg :: T.Text + msg = _message diag + regex :: T.Text -- TODO: Support multiple packages suggestion + regex = "It is a member of the hidden package [\8216']([a-zA-Z0-9-]*[a-zA-Z0-9])(-([0-9\\.]*))?[\8217']" + -- Have to do this matching because `Regex.TDFA` doesn't(?) support + -- not-capturing groups like (?:message) + getMatch :: (T.Text, T.Text, T.Text, [T.Text]) -> [(T.Text, T.Text)] + getMatch (_, _, _, []) = [] + getMatch (_, _, _, [dependency, _, cleanVersion]) = [(dependency, cleanVersion)] + getMatch (_, _, _, _) = error "Impossible pattern matching case" + +command :: Recorder (WithPriority Log) -> CommandFunction IdeState CabalAddCommandParams +command recorder state _ params@(CabalAddCommandParams {cabalPath = path, verTxtDocId = verTxtDocId, buildTarget = target, dependency = dep, version = mbVer}) = do + logWith recorder Debug $ LogCalledCabalAddCommand params + let specifiedDep = case mbVer of + Nothing -> dep + Just ver -> dep <> " ^>=" <> ver + caps <- lift pluginGetClientCapabilities + let env = (state, caps, verTxtDocId) + edit <- getDependencyEdit recorder env path target (fromList [T.unpack specifiedDep]) + void $ lift $ pluginSendRequest SMethod_WorkspaceApplyEdit (ApplyWorkspaceEditParams Nothing edit) (\_ -> pure ()) + logWith recorder Debug LogExecutedCommand + pure $ InR Null + +-- | Constructs prerequisites for the @executeConfig@ +-- and runs it, given path to the cabal file and a dependency message. +-- Given the new contents of the cabal file constructs and returns the @edit@. +-- Inspired by @main@ in cabal-add, +-- Distribution.Client.Main +getDependencyEdit :: MonadIO m => Recorder (WithPriority Log) -> (IdeState, ClientCapabilities, VersionedTextDocumentIdentifier) -> + FilePath -> Maybe String -> NonEmpty String -> ExceptT PluginError m WorkspaceEdit +getDependencyEdit recorder env cabalFilePath buildTarget dependency = do + let (state, caps, verTxtDocId) = env + (mbCnfOrigContents, mbFields, mbPackDescr) <- liftIO $ runAction "cabal.cabal-add" state $ do + contents <- useWithStale GetFileContents $ toNormalizedFilePath cabalFilePath + inFields <- useWithStale ParseCabalFields $ toNormalizedFilePath cabalFilePath + inPackDescr <- useWithStale ParseCabalFile $ toNormalizedFilePath cabalFilePath + let mbCnfOrigContents = case snd . fst <$> contents of + Just (Just txt) -> Just $ encodeUtf8 txt + _ -> Nothing + let mbFields = fst <$> inFields + let mbPackDescr = fst <$> inPackDescr + pure (mbCnfOrigContents, mbFields, mbPackDescr) + + -- Check if required info was received, + -- otherwise fall back on other options. + (cnfOrigContents, fields, packDescr) <- do + cnfOrigContents <- case mbCnfOrigContents of + (Just cnfOrigContents) -> pure cnfOrigContents + Nothing -> readCabalFile cabalFilePath + (fields, packDescr) <- case (mbFields, mbPackDescr) of + (Just fields, Just packDescr) -> pure (fields, packDescr) + (_, _) -> case parseCabalFile cabalFilePath cnfOrigContents of + Left err -> throwE $ PluginInternalError $ T.pack err + Right (f ,gpd) -> pure (f, gpd) + pure (cnfOrigContents, fields, packDescr) + + let inputs = do + let rcnfComponent = buildTarget + let specVer = specVersion $ packageDescription packDescr + cmp <- resolveComponent cabalFilePath (fields, packDescr) rcnfComponent + deps <- traverse (validateDependency specVer) dependency + pure (fields, packDescr, cmp, deps) + + (cnfFields, origPackDescr, cnfComponent, cnfDependencies) <- case inputs of + Left err -> throwE $ PluginInternalError $ T.pack err + Right pair -> pure pair + + case executeConfig (validateChanges origPackDescr) (Config {..}) of + Nothing -> throwE $ PluginInternalError $ T.pack $ "Cannot extend build-depends in " ++ cabalFilePath + Just newContents -> do + let edit = diffText caps (verTxtDocId, T.decodeUtf8 cnfOrigContents) (T.decodeUtf8 newContents) SkipDeletions + logWith recorder Debug $ LogCreatedEdit edit + pure edit + +-- | Given a path to a haskell file, returns the closest cabal file. +-- If cabal file wasn't found, gives Nothing. +findResponsibleCabalFile :: FilePath -> IO (Maybe FilePath) +findResponsibleCabalFile haskellFilePath = do + let dirPath = dropFileName haskellFilePath + allDirPaths = reverse $ scanl1 () (splitPath dirPath) -- sorted from most to least specific + go allDirPaths + where + go [] = pure Nothing + go (path:ps) = do + objects <- listDirectory path + let objectsWithPaths = map (\obj -> path <> obj) objects + objectsCabalExtension = filter (\c -> takeExtension c == ".cabal") objectsWithPaths + cabalFiles <- filterM (\c -> doesFileExist c) objectsCabalExtension + case safeHead cabalFiles of + Nothing -> go ps + Just cabalFile -> pure $ Just cabalFile + +-- | Gives cabal file's contents or throws error. +-- Inspired by @readCabalFile@ in cabal-add, +-- Distribution.Client.Main +-- +-- This is a fallback option! +-- Use only if the `GetFileContents` fails. +readCabalFile :: MonadIO m => FilePath -> ExceptT PluginError m ByteString +readCabalFile fileName = do + cabalFileExists <- liftIO $ doesFileExist fileName + if cabalFileExists + then snd . patchQuirks <$> liftIO (B.readFile fileName) + else throwE $ PluginInternalError $ T.pack ("Failed to read cabal file at " <> fileName) diff --git a/plugins/hls-cabal-plugin/test/CabalAdd.hs b/plugins/hls-cabal-plugin/test/CabalAdd.hs new file mode 100644 index 0000000000..f6bc7dbde0 --- /dev/null +++ b/plugins/hls-cabal-plugin/test/CabalAdd.hs @@ -0,0 +1,143 @@ +{-# LANGUAGE OverloadedStrings #-} + +module CabalAdd ( + cabalAddTests, +) where + +import Control.Lens ((^.)) +import Control.Lens.Fold ((^?)) +import qualified Data.Maybe as Maybe +import qualified Data.Text as T +import qualified Data.Text.Internal.Search as T +import Distribution.Utils.Generic (safeHead) +import Ide.Plugin.Cabal.CabalAdd (hiddenPackageSuggestion) +import qualified Language.LSP.Protocol.Lens as L +import Language.LSP.Protocol.Types (Diagnostic (..), mkRange) +import System.FilePath +import Test.Hls (Session, TestTree, _R, anyMessage, + assertEqual, documentContents, + executeCodeAction, + expectFailBecause, + getAllCodeActions, + getDocumentEdit, liftIO, openDoc, + skipManyTill, testCase, testGroup, + waitForDiagnosticsFrom, (@?=)) +import Utils + +cabalAddTests :: TestTree +cabalAddTests = + testGroup + "CabalAdd Tests" + [ runHaskellTestCaseSession "Code Actions - Can add hidden package" ("cabal-add-testdata" "cabal-add-exe") + (generateAddDependencyTestSession "cabal-add-exe.cabal" ("src" "Main.hs") "split" [253]) + , runHaskellTestCaseSession "Code Actions - Can add hidden package to a library" ("cabal-add-testdata" "cabal-add-lib") + (generateAddDependencyTestSession "cabal-add-lib.cabal" ("src" "MyLib.hs") "split" [348]) + , runHaskellTestCaseSession "Code Actions - Can add hidden package to a test" ("cabal-add-testdata" "cabal-add-tests") + (generateAddDependencyTestSession "cabal-add-tests.cabal" ("test" "Main.hs") "split" [478]) + , runHaskellTestCaseSession "Code Actions - Can add hidden package to a benchmark" ("cabal-add-testdata" "cabal-add-bench") + (generateAddDependencyTestSession "cabal-add-bench.cabal" ("bench" "Main.hs") "split" [403]) + , testHiddenPackageSuggestions "Check CabalAdd's parser, no version" + [ "It is a member of the hidden package 'base'" + , "It is a member of the hidden package 'Blammo-wai'" + , "It is a member of the hidden package 'BlastHTTP'" + , "It is a member of the hidden package 'CC-delcont-ref-tf'" + , "It is a member of the hidden package '3d-graphics-examples'" + , "It is a member of the hidden package 'AAI'" + , "It is a member of the hidden package 'AWin32Console'" + ] + [ ("base", T.empty) + , ("Blammo-wai", T.empty) + , ("BlastHTTP", T.empty) + , ("CC-delcont-ref-tf", T.empty) + , ("3d-graphics-examples", T.empty) + , ("AAI", T.empty) + , ("AWin32Console", T.empty) + ] + , testHiddenPackageSuggestions "Check CabalAdd's parser, with version" + [ "It is a member of the hidden package 'base-0.1.0.0'" + , "It is a member of the hidden package 'Blammo-wai-0.11.0'" + , "It is a member of the hidden package 'BlastHTTP-2.6.4.3'" + , "It is a member of the hidden package 'CC-delcont-ref-tf-0.0.0.2'" + , "It is a member of the hidden package '3d-graphics-examples-1.1.6'" + , "It is a member of the hidden package 'AAI-0.1'" + , "It is a member of the hidden package 'AWin32Console-1.19.1'" + ] + [ ("base","0.1.0.0") + , ("Blammo-wai", "0.11.0") + , ("BlastHTTP", "2.6.4.3") + , ("CC-delcont-ref-tf", "0.0.0.2") + , ("3d-graphics-examples", "1.1.6") + , ("AAI", "0.1") + , ("AWin32Console", "1.19.1") + ] + , testHiddenPackageSuggestions "Check CabalAdd's parser, no version, unicode comma" + [ "It is a member of the hidden package \8216base\8217" + , "It is a member of the hidden package \8216Blammo-wai\8217" + , "It is a member of the hidden package \8216BlastHTTP\8217" + , "It is a member of the hidden package \8216CC-delcont-ref-tf\8217" + , "It is a member of the hidden package \8216AAI\8217" + , "It is a member of the hidden package \8216AWin32Console\8217" + ] + [ ("base", T.empty) + , ("Blammo-wai", T.empty) + , ("BlastHTTP", T.empty) + , ("CC-delcont-ref-tf", T.empty) + , ("AAI", T.empty) + , ("AWin32Console", T.empty) + ] + , testHiddenPackageSuggestions "Check CabalAdd's parser, with version, unicode comma" + [ "It is a member of the hidden package \8216base-0.1.0.0\8217" + , "It is a member of the hidden package \8216Blammo-wai-0.11.0\8217" + , "It is a member of the hidden package \8216BlastHTTP-2.6.4.3\8217" + , "It is a member of the hidden package \8216CC-delcont-ref-tf-0.0.0.2\8217" + , "It is a member of the hidden package \8216AAI-0.1\8217" + , "It is a member of the hidden package \8216AWin32Console-1.19.1\8217" + ] + [ ("base","0.1.0.0") + , ("Blammo-wai", "0.11.0") + , ("BlastHTTP", "2.6.4.3") + , ("CC-delcont-ref-tf", "0.0.0.2") + , ("AAI", "0.1") + , ("AWin32Console", "1.19.1") + ] + , expectFailBecause "TODO fix regex for these cases" $ + testHiddenPackageSuggestions "Check CabalAdd's parser, with version, unicode comma" + [ "It is a member of the hidden package \82163d-graphics-examples\8217" + , "It is a member of the hidden package \82163d-graphics-examples-1.1.6\8217" + ] + [ ("3d-graphics-examples", T.empty) + , ("3d-graphics-examples", "1.1.6") + ] + ] + where + generateAddDependencyTestSession :: FilePath -> FilePath -> T.Text -> [Int] -> Session () + generateAddDependencyTestSession cabalFile haskellFile dependency indicesRes = do + hsdoc <- openDoc haskellFile "haskell" + cabDoc <- openDoc cabalFile "cabal" + _ <- waitForDiagnosticsFrom hsdoc + cas <- Maybe.mapMaybe (^? _R) <$> getAllCodeActions hsdoc + let selectedCas = filter (\ca -> "Add dependency" `T.isPrefixOf` (ca ^. L.title)) cas + mapM_ executeCodeAction selectedCas + _ <- skipManyTill anyMessage $ getDocumentEdit cabDoc -- Wait for the changes in cabal file + contents <- documentContents cabDoc + liftIO $ assertEqual (T.unpack dependency <> " isn't found in the cabal file") indicesRes (T.indices dependency contents) + testHiddenPackageSuggestions :: String -> [T.Text] -> [(T.Text, T.Text)] -> TestTree + testHiddenPackageSuggestions testTitle messages suggestions = + let diags = map (\msg -> messageToDiagnostic msg ) messages + suggestions' = map (safeHead . hiddenPackageSuggestion) diags + assertions = zipWith (@?=) suggestions' (map Just suggestions) + testNames = map (\(f, s) -> "Check if " ++ T.unpack f ++ (if s == "" then "" else "-") ++ T.unpack s ++ " was parsed correctly") suggestions + test = testGroup testTitle $ zipWith testCase testNames assertions + in test + messageToDiagnostic :: T.Text -> Diagnostic + messageToDiagnostic msg = Diagnostic { + _range = mkRange 0 0 0 0 + , _severity = Nothing + , _code = Nothing + , _source = Nothing + , _message = msg + , _relatedInformation = Nothing + , _tags = Nothing + , _codeDescription = Nothing + , _data_ = Nothing + } diff --git a/plugins/hls-cabal-plugin/test/Main.hs b/plugins/hls-cabal-plugin/test/Main.hs index 2009352bbd..00e39583f4 100644 --- a/plugins/hls-cabal-plugin/test/Main.hs +++ b/plugins/hls-cabal-plugin/test/Main.hs @@ -6,6 +6,7 @@ module Main ( main, ) where +import CabalAdd (cabalAddTests) import Completer (completerTests) import Context (contextTests) import Control.Lens ((^.)) @@ -16,7 +17,6 @@ import Data.Either (isRight) import Data.List.Extra (nubOrdOn) import qualified Data.Maybe as Maybe import qualified Data.Text as T -import qualified Data.Text as Text import Ide.Plugin.Cabal.LicenseSuggest (licenseErrorSuggestion) import qualified Ide.Plugin.Cabal.Parse as Lib import qualified Language.LSP.Protocol.Lens as L @@ -166,7 +166,7 @@ codeActionTests = testGroup "Code Actions" contents <- documentContents doc liftIO $ contents - @?= Text.unlines + @?= T.unlines [ "cabal-version: 3.0" , "name: licenseCodeAction" , "version: 0.1.0.0" @@ -190,7 +190,7 @@ codeActionTests = testGroup "Code Actions" contents <- documentContents doc liftIO $ contents - @?= Text.unlines + @?= T.unlines [ "cabal-version: 3.0" , "name: licenseCodeAction2" , "version: 0.1.0.0" @@ -222,6 +222,7 @@ codeActionTests = testGroup "Code Actions" ]) cas mapM_ executeCodeAction selectedCas pure () + , cabalAddTests ] where getLicenseAction :: T.Text -> [Command |? CodeAction] -> [CodeAction] diff --git a/plugins/hls-cabal-plugin/test/Utils.hs b/plugins/hls-cabal-plugin/test/Utils.hs index bcafa01fac..2733f94fd0 100644 --- a/plugins/hls-cabal-plugin/test/Utils.hs +++ b/plugins/hls-cabal-plugin/test/Utils.hs @@ -8,15 +8,20 @@ import Control.Monad (guard) import Data.List (sort) import Data.Proxy (Proxy (Proxy)) import qualified Data.Text as T -import Ide.Plugin.Cabal (descriptor) +import Ide.Plugin.Cabal (descriptor, + haskellInteractionDescriptor) import qualified Ide.Plugin.Cabal import Ide.Plugin.Cabal.Completion.Types import System.FilePath import Test.Hls + cabalPlugin :: PluginTestDescriptor Ide.Plugin.Cabal.Log cabalPlugin = mkPluginTestDescriptor descriptor "cabal" +cabalHaskellPlugin :: PluginTestDescriptor Ide.Plugin.Cabal.Log +cabalHaskellPlugin = mkPluginTestDescriptor haskellInteractionDescriptor "cabal-haskell" + simpleCabalPrefixInfoFromPos :: Position -> T.Text -> CabalPrefixInfo simpleCabalPrefixInfoFromPos pos prefix = CabalPrefixInfo @@ -45,10 +50,17 @@ filePathComplTestDir = addTrailingPathSeparator $ testDataDir "filepath-comp runCabalTestCaseSession :: TestName -> FilePath -> Session () -> TestTree runCabalTestCaseSession title subdir = testCase title . runCabalSession subdir +runHaskellTestCaseSession :: TestName -> FilePath -> Session () -> TestTree +runHaskellTestCaseSession title subdir = testCase title . runHaskellAndCabalSession subdir + runCabalSession :: FilePath -> Session a -> IO a runCabalSession subdir = failIfSessionTimeout . runSessionWithServer def cabalPlugin (testDataDir subdir) +runHaskellAndCabalSession :: FilePath -> Session a -> IO a +runHaskellAndCabalSession subdir = + failIfSessionTimeout . runSessionWithServer def (cabalPlugin <> cabalHaskellPlugin) (testDataDir subdir) + runCabalGoldenSession :: TestName -> FilePath -> FilePath -> (TextDocumentIdentifier -> Session ()) -> TestTree runCabalGoldenSession title subdir fp act = goldenWithCabalDoc def cabalPlugin title testDataDir (subdir fp) "golden" "cabal" act diff --git a/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-bench/bench/Main.hs b/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-bench/bench/Main.hs new file mode 100644 index 0000000000..e5c42398f2 --- /dev/null +++ b/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-bench/bench/Main.hs @@ -0,0 +1,6 @@ +module Main (main) where + +import Data.List.Split + +main :: IO () +main = putStrLn "Test suite not yet implemented." diff --git a/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-bench/cabal-add-bench.cabal b/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-bench/cabal-add-bench.cabal new file mode 100644 index 0000000000..b58a6d3302 --- /dev/null +++ b/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-bench/cabal-add-bench.cabal @@ -0,0 +1,17 @@ +cabal-version: 2.4 +name: cabal-add-bench +version: 0.1.0.0 +license: NONE +author: George Gerasev +maintainer: george30032002@gmail.com +build-type: Simple + +common warnings + ghc-options: -Wall + +benchmark benchmark + type: exitcode-stdio-1.0 + ghc-options: -threaded + main-is: Main.hs + hs-source-dirs: bench + build-depends: base diff --git a/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-exe/cabal-add-exe.cabal b/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-exe/cabal-add-exe.cabal new file mode 100644 index 0000000000..a3499bbf97 --- /dev/null +++ b/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-exe/cabal-add-exe.cabal @@ -0,0 +1,15 @@ +cabal-version: 2.4 +name: cabal-add-exe +version: 0.1.0.0 +build-type: Simple + +executable cabal-add-exe + main-is: Main.hs + hs-source-dirs: src + ghc-options: -Wall + build-depends: base + default-language: Haskell2010 + +library + build-depends: base >= 4 && < 5 + ghc-options: -Wall diff --git a/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-exe/src/Main.hs b/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-exe/src/Main.hs new file mode 100644 index 0000000000..0bf3e99dae --- /dev/null +++ b/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-exe/src/Main.hs @@ -0,0 +1,5 @@ +module Main where + +import Data.List.Split + +main = putStrLn "Hello, Haskell!" \ No newline at end of file diff --git a/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-lib/cabal-add-lib.cabal b/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-lib/cabal-add-lib.cabal new file mode 100644 index 0000000000..b00b45bb6b --- /dev/null +++ b/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-lib/cabal-add-lib.cabal @@ -0,0 +1,17 @@ +cabal-version: 2.4 +name: cabal-add-lib +version: 0.1.0.0 +license: NONE +author: George Gerasev +maintainer: george30032002@gmail.com +build-type: Simple + +common warnings + ghc-options: -Wall + +library + import: warnings + exposed-modules: MyLib + build-depends: base + hs-source-dirs: src + default-language: Haskell2010 diff --git a/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-lib/src/MyLib.hs b/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-lib/src/MyLib.hs new file mode 100644 index 0000000000..bbb506d001 --- /dev/null +++ b/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-lib/src/MyLib.hs @@ -0,0 +1,6 @@ +module MyLib (someFunc) where + +import Data.List.Split + +someFunc :: IO () +someFunc = putStrLn "someFunc" diff --git a/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-tests/cabal-add-tests.cabal b/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-tests/cabal-add-tests.cabal new file mode 100644 index 0000000000..d217f8c4d5 --- /dev/null +++ b/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-tests/cabal-add-tests.cabal @@ -0,0 +1,18 @@ +cabal-version: 2.4 +name: cabal-add-tests +version: 0.1.0.0 +license: NONE +author: George Gerasev +maintainer: george30032002@gmail.com +build-type: Simple + +common warnings + ghc-options: -Wall + +test-suite cabal-add-tests-test + import: warnings + default-language: Haskell2010 + type: exitcode-stdio-1.0 + hs-source-dirs: test + main-is: Main.hs + build-depends: base diff --git a/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-tests/test/Main.hs b/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-tests/test/Main.hs new file mode 100644 index 0000000000..e5c42398f2 --- /dev/null +++ b/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-tests/test/Main.hs @@ -0,0 +1,6 @@ +module Main (main) where + +import Data.List.Split + +main :: IO () +main = putStrLn "Test suite not yet implemented." diff --git a/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal.project b/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal.project new file mode 100644 index 0000000000..dfa2feed39 --- /dev/null +++ b/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal.project @@ -0,0 +1,4 @@ +packages: cabal-add-exe + cabal-add-lib + cabal-add-tests + cabal-add-bench diff --git a/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/hie.yaml b/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/hie.yaml new file mode 100644 index 0000000000..f0c7014d7f --- /dev/null +++ b/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/hie.yaml @@ -0,0 +1,2 @@ +cradle: + cabal: \ No newline at end of file diff --git a/src/HlsPlugins.hs b/src/HlsPlugins.hs index f08ae187cd..87a1af7392 100644 --- a/src/HlsPlugins.hs +++ b/src/HlsPlugins.hs @@ -152,6 +152,7 @@ idePlugins recorder = pluginDescToIdePlugins allPlugins allPlugins = #if hls_cabal let pId = "cabal" in Cabal.descriptor (pluginRecorder pId) pId : + let caId = "cabalHaskellIntegration" in Cabal.haskellInteractionDescriptor (pluginRecorder caId) caId : #endif #if hls_pragmas Pragmas.suggestPragmaDescriptor "pragmas-suggest" : diff --git a/stack-lts22.yaml b/stack-lts22.yaml index 80007a898c..ecd17a99c2 100644 --- a/stack-lts22.yaml +++ b/stack-lts22.yaml @@ -37,6 +37,9 @@ extra-deps: - trial-optparse-applicative-0.0.0.0 - trial-tomland-0.0.0.0 - validation-selective-0.2.0.0 + - cabal-add-0.1 + - cabal-install-parsers-0.6.1.1 + configure-options: ghcide: @@ -51,6 +54,8 @@ flags: ghc-lib: true retrie: BuildExecutable: false + cabal-add: + cabal-syntax: true nix: packages: [icu libcxx zlib] diff --git a/stack.yaml b/stack.yaml index 8df73f646b..8df29e1b00 100644 --- a/stack.yaml +++ b/stack.yaml @@ -38,6 +38,8 @@ extra-deps: - trial-0.0.0.0 - trial-optparse-applicative-0.0.0.0 - trial-tomland-0.0.0.0 + - cabal-add-0.1 + - cabal-install-parsers-0.6.1.1 configure-options: ghcide: @@ -52,6 +54,8 @@ flags: ghc-lib: true retrie: BuildExecutable: false + cabal-add: + cabal-syntax: true nix: packages: [icu libcxx zlib] diff --git a/test/testdata/schema/ghc94/default-config.golden.json b/test/testdata/schema/ghc94/default-config.golden.json index 5f881ff00e..6deedfb1cf 100644 --- a/test/testdata/schema/ghc94/default-config.golden.json +++ b/test/testdata/schema/ghc94/default-config.golden.json @@ -24,6 +24,9 @@ "path": "cabal-gild" } }, + "cabalHaskellIntegration": { + "globalOn": true + }, "callHierarchy": { "globalOn": true }, diff --git a/test/testdata/schema/ghc94/vscode-extension-schema.golden.json b/test/testdata/schema/ghc94/vscode-extension-schema.golden.json index 5da4a27dd6..e8572b47e1 100644 --- a/test/testdata/schema/ghc94/vscode-extension-schema.golden.json +++ b/test/testdata/schema/ghc94/vscode-extension-schema.golden.json @@ -41,6 +41,12 @@ "scope": "resource", "type": "boolean" }, + "haskell.plugin.cabalHaskellIntegration.globalOn": { + "default": true, + "description": "Enables cabalHaskellIntegration plugin", + "scope": "resource", + "type": "boolean" + }, "haskell.plugin.callHierarchy.globalOn": { "default": true, "description": "Enables callHierarchy plugin", diff --git a/test/testdata/schema/ghc96/default-config.golden.json b/test/testdata/schema/ghc96/default-config.golden.json index 5f881ff00e..6deedfb1cf 100644 --- a/test/testdata/schema/ghc96/default-config.golden.json +++ b/test/testdata/schema/ghc96/default-config.golden.json @@ -24,6 +24,9 @@ "path": "cabal-gild" } }, + "cabalHaskellIntegration": { + "globalOn": true + }, "callHierarchy": { "globalOn": true }, diff --git a/test/testdata/schema/ghc96/vscode-extension-schema.golden.json b/test/testdata/schema/ghc96/vscode-extension-schema.golden.json index 5da4a27dd6..e8572b47e1 100644 --- a/test/testdata/schema/ghc96/vscode-extension-schema.golden.json +++ b/test/testdata/schema/ghc96/vscode-extension-schema.golden.json @@ -41,6 +41,12 @@ "scope": "resource", "type": "boolean" }, + "haskell.plugin.cabalHaskellIntegration.globalOn": { + "default": true, + "description": "Enables cabalHaskellIntegration plugin", + "scope": "resource", + "type": "boolean" + }, "haskell.plugin.callHierarchy.globalOn": { "default": true, "description": "Enables callHierarchy plugin", diff --git a/test/testdata/schema/ghc98/default-config.golden.json b/test/testdata/schema/ghc98/default-config.golden.json index 5f881ff00e..6deedfb1cf 100644 --- a/test/testdata/schema/ghc98/default-config.golden.json +++ b/test/testdata/schema/ghc98/default-config.golden.json @@ -24,6 +24,9 @@ "path": "cabal-gild" } }, + "cabalHaskellIntegration": { + "globalOn": true + }, "callHierarchy": { "globalOn": true }, diff --git a/test/testdata/schema/ghc98/vscode-extension-schema.golden.json b/test/testdata/schema/ghc98/vscode-extension-schema.golden.json index 5da4a27dd6..e8572b47e1 100644 --- a/test/testdata/schema/ghc98/vscode-extension-schema.golden.json +++ b/test/testdata/schema/ghc98/vscode-extension-schema.golden.json @@ -41,6 +41,12 @@ "scope": "resource", "type": "boolean" }, + "haskell.plugin.cabalHaskellIntegration.globalOn": { + "default": true, + "description": "Enables cabalHaskellIntegration plugin", + "scope": "resource", + "type": "boolean" + }, "haskell.plugin.callHierarchy.globalOn": { "default": true, "description": "Enables callHierarchy plugin",