diff --git a/cabal.project b/cabal.project index 8d8bd080af..1c69268b8c 100644 --- a/cabal.project +++ b/cabal.project @@ -5,6 +5,7 @@ packages: ./ghcide ./hls-plugin-api ./hls-test-utils + ../lsp/lsp index-state: 2025-08-08T12:31:54Z diff --git a/ghcide/src/Development/IDE/Core/FileStore.hs b/ghcide/src/Development/IDE/Core/FileStore.hs index e545ec7b14..382b4781c9 100644 --- a/ghcide/src/Development/IDE/Core/FileStore.hs +++ b/ghcide/src/Development/IDE/Core/FileStore.hs @@ -7,6 +7,7 @@ module Development.IDE.Core.FileStore( getFileContents, getUriContents, getVersionedTextDoc, + getVersionedTextDocForNormalizedFilePath, setFileModified, setSomethingModified, fileStoreRules, @@ -256,6 +257,14 @@ getVersionedTextDoc doc = do Nothing -> 0 return (VersionedTextDocumentIdentifier uri ver) +getVersionedTextDocForNormalizedFilePath :: NormalizedFilePath -> Action VersionedTextDocumentIdentifier +getVersionedTextDocForNormalizedFilePath nfp = do + mvf <- getVirtualFile nfp + let ver = case mvf of + Just (VirtualFile lspver _ _) -> lspver + Nothing -> 0 + return (VersionedTextDocumentIdentifier (fromNormalizedUri $ filePathToUri' nfp) ver) + fileStoreRules :: Recorder (WithPriority Log) -> (NormalizedFilePath -> Action Bool) -> Rules () fileStoreRules recorder isWatched = do getModificationTimeRule recorder diff --git a/ghcide/src/Development/IDE/Core/Shake.hs b/ghcide/src/Development/IDE/Core/Shake.hs index 2fbaa892fa..60b18c069e 100644 --- a/ghcide/src/Development/IDE/Core/Shake.hs +++ b/ghcide/src/Development/IDE/Core/Shake.hs @@ -29,6 +29,7 @@ module Development.IDE.Core.Shake( GetModificationTime(GetModificationTime, GetModificationTime_, missingFileDiagnostics), shakeOpen, shakeShut, shakeEnqueue, + shakeRestart, newSession, use, useNoFile, uses, useWithStaleFast, useWithStaleFast', delayedAction, useWithSeparateFingerprintRule, diff --git a/ghcide/src/Development/IDE/GHC/Compat/Core.hs b/ghcide/src/Development/IDE/GHC/Compat/Core.hs index 42f654b609..e8c3f6c16e 100644 --- a/ghcide/src/Development/IDE/GHC/Compat/Core.hs +++ b/ghcide/src/Development/IDE/GHC/Compat/Core.hs @@ -185,6 +185,7 @@ module Development.IDE.GHC.Compat.Core ( unLocA, LocatedAn, GHC.LocatedA, + GHC.SrcSpanAnnA, GHC.AnnListItem(..), GHC.NameAnn(..), SrcLoc.RealLocated, diff --git a/ghcide/src/Development/IDE/LSP/Notifications.hs b/ghcide/src/Development/IDE/LSP/Notifications.hs index 4f5475442c..f271fe0ff7 100644 --- a/ghcide/src/Development/IDE/LSP/Notifications.hs +++ b/ghcide/src/Development/IDE/LSP/Notifications.hs @@ -33,10 +33,12 @@ import Development.IDE.Core.OfInterest hiding (Log, LogShake) import Development.IDE.Core.Service hiding (Log, LogShake) import Development.IDE.Core.Shake hiding (Log) import qualified Development.IDE.Core.Shake as Shake +import qualified Development.IDE.Types.Shake as Shake import Development.IDE.Types.Location import Ide.Logger import Ide.Types import Numeric.Natural +import Development.IDE.Core.RuleTypes (GhcSessionIO(..)) data Log = LogShake Shake.Log @@ -46,6 +48,7 @@ data Log | LogSavedTextDocument !Uri | LogClosedTextDocument !Uri | LogWatchedFileEvents !Text.Text + | LogSessionRestart | LogWarnNoWatchedFilesSupport deriving Show @@ -59,6 +62,7 @@ instance Pretty Log where LogClosedTextDocument uri -> "Closed text document:" <+> pretty (getUri uri) LogWatchedFileEvents msg -> "Watched file events:" <+> pretty msg LogWarnNoWatchedFilesSupport -> "Client does not support watched files. Falling back to OS polling" + LogSessionRestart -> "Restarting shake session globally" whenUriFile :: Uri -> (NormalizedFilePath -> IO ()) -> IO () whenUriFile uri act = whenJust (LSP.uriToFilePath uri) $ act . toNormalizedFilePath' @@ -147,6 +151,12 @@ descriptor recorder plId = (defaultPluginDescriptor plId desc) { pluginNotificat success <- registerFileWatches globs unless success $ liftIO $ logWith recorder Warning LogWarnNoWatchedFilesSupport + , mkPluginNotificationHandler LSP.SMethod_WorkspaceDidRenameFiles $ + \ide vfs _ _ -> liftIO $ do + logWith recorder Debug LogSessionRestart + Shake.shakeRestart (cmapWithPrio LogShake recorder) ide (VFSModified vfs) "" [] $ do + return [Shake.toNoFileKey GhcSessionIO] + pure () ], -- The ghcide descriptors should come last'ish so that the notification handlers diff --git a/ghcide/src/Development/IDE/Main.hs b/ghcide/src/Development/IDE/Main.hs index 58cffe27e7..18d6928058 100644 --- a/ghcide/src/Development/IDE/Main.hs +++ b/ghcide/src/Development/IDE/Main.hs @@ -129,6 +129,7 @@ import System.Process (readProcessWithExitCo import System.Random (newStdGen) import System.Time.Extra (Seconds, offsetTime, showDuration) +import qualified Language.LSP.Protocol.Types as LSP data Log = LogHeapStats !HeapStats.Log @@ -300,7 +301,61 @@ defaultMain recorder Arguments{..} = withHeapStats (cmapWithPrio LogHeapStats re let hlsPlugin = asGhcIdePlugin (cmapWithPrio LogPluginHLS recorder) argsHlsPlugins hlsCommands = allLspCmdIds' pid argsHlsPlugins plugins = hlsPlugin <> argsGhcidePlugin - options = argsLspOptions { LSP.optExecuteCommandCommands = LSP.optExecuteCommandCommands argsLspOptions <> Just hlsCommands } + options = argsLspOptions { + LSP.optExecuteCommandCommands = LSP.optExecuteCommandCommands argsLspOptions <> Just hlsCommands, + LSP.optWorkspaceWillRenameFileOperationRegistrationOptions = Just $ + LSP.FileOperationRegistrationOptions + [LSP.FileOperationFilter + {_scheme= Just "file", _pattern = LSP.FileOperationPattern + { {-| + The glob pattern to match. Glob patterns can have the following syntax: + - `*` to match one or more characters in a path segment + - `?` to match on one character in a path segment + - `**` to match any number of path segments, including none + - `{}` to group sub patterns into an OR expression. (e.g. `**​/*.{ts,js}` matches all TypeScript and JavaScript files) + - `[]` to declare a range of characters to match in a path segment (e.g., `example.[0-9]` to match on `example.0`, `example.1`, …) + - `[!...]` to negate a range of characters to match in a path segment (e.g., `example.[!0-9]` to match on `example.a`, `example.b`, but not `example.0`) + -} + _glob = "**/*.hs" + , {-| + Whether to match files or folders with this pattern. + + Matches both if undefined. + -} + _matches = Nothing + , {-| + Additional options used during matching. + -} + _options = Nothing + } + }], + LSP.optWorkspaceDidRenameFileOperationRegistrationOptions = Just $ + LSP.FileOperationRegistrationOptions + [LSP.FileOperationFilter + {_scheme= Just "file", _pattern = LSP.FileOperationPattern + { {-| + The glob pattern to match. Glob patterns can have the following syntax: + - `*` to match one or more characters in a path segment + - `?` to match on one character in a path segment + - `**` to match any number of path segments, including none + - `{}` to group sub patterns into an OR expression. (e.g. `**​/*.{ts,js}` matches all TypeScript and JavaScript files) + - `[]` to declare a range of characters to match in a path segment (e.g., `example.[0-9]` to match on `example.0`, `example.1`, …) + - `[!...]` to negate a range of characters to match in a path segment (e.g., `example.[!0-9]` to match on `example.a`, `example.b`, but not `example.0`) + -} + _glob = "**/*.hs" + , {-| + Whether to match files or folders with this pattern. + + Matches both if undefined. + -} + _matches = Nothing + , {-| + Additional options used during matching. + -} + _options = Nothing + } + }] + } argsParseConfig = getConfigFromNotification argsHlsPlugins rules = do argsRules diff --git a/ghcide/src/Development/IDE/Types/KnownTargets.hs b/ghcide/src/Development/IDE/Types/KnownTargets.hs index 6ae6d52ba3..18ca0e31f1 100644 --- a/ghcide/src/Development/IDE/Types/KnownTargets.hs +++ b/ghcide/src/Development/IDE/Types/KnownTargets.hs @@ -24,7 +24,7 @@ data KnownTargets = KnownTargets -- | 'normalisingMap' is a cached copy of `HMap.mapKey const targetMap` -- -- At startup 'GetLocatedImports' is called on all known files. Say you have 10000 - -- modules in your project then this leads to 10000 calls to 'GetLocatedImports' + -- modules in your projecknownTargetsVart then this leads to 10000 calls to 'GetLocatedImports' -- running concurrently. -- -- In `GetLocatedImports` the known targets are consulted and the targetsMap diff --git a/haskell-language-server.cabal b/haskell-language-server.cabal index 23260a5393..f507c861fd 100644 --- a/haskell-language-server.cabal +++ b/haskell-language-server.cabal @@ -261,6 +261,7 @@ library hls-cabal-plugin Ide.Plugin.Cabal.CabalAdd.Command Ide.Plugin.Cabal.CabalAdd.CodeAction Ide.Plugin.Cabal.CabalAdd.Types + Ide.Plugin.Cabal.CabalAdd.Rename Ide.Plugin.Cabal.Orphans Ide.Plugin.Cabal.Outline Ide.Plugin.Cabal.Parse @@ -597,6 +598,7 @@ library hls-rename-plugin exposed-modules: Ide.Plugin.Rename hs-source-dirs: plugins/hls-rename-plugin/src build-depends: + , text-rope ^>=0.3 , containers , ghc , ghcide == 2.12.0.0 diff --git a/hls-plugin-api/src/Ide/Types.hs b/hls-plugin-api/src/Ide/Types.hs index 314049b826..539d488758 100644 --- a/hls-plugin-api/src/Ide/Types.hs +++ b/hls-plugin-api/src/Ide/Types.hs @@ -607,6 +607,9 @@ instance PluginMethod Request Method_WorkspaceExecuteCommand where instance PluginMethod Request (Method_CustomMethod m) where handlesRequest _ _ _ _ = HandlesRequest +instance PluginMethod Request Method_WorkspaceWillRenameFiles where + handlesRequest _ _ desc conf = pluginEnabledGlobally desc conf + -- Plugin Notifications instance PluginMethod Notification Method_TextDocumentDidOpen where @@ -629,6 +632,14 @@ instance PluginMethod Notification Method_WorkspaceDidChangeConfiguration where -- This method has no URI parameter, thus no call to 'pluginResponsible'. handlesRequest _ _ desc conf = pluginEnabledGlobally desc conf +instance PluginMethod Notification Method_WorkspaceDidRenameFiles where + handlesRequest :: SMethod Method_WorkspaceDidRenameFiles + -> MessageParams Method_WorkspaceDidRenameFiles + -> PluginDescriptor c + -> Config + -> HandleRequestResult + handlesRequest _ _ desc conf = pluginEnabledGlobally desc conf + instance PluginMethod Notification Method_Initialized where -- This method has no URI parameter, thus no call to 'pluginResponsible'. handlesRequest _ _ desc conf = pluginEnabledGlobally desc conf @@ -838,6 +849,8 @@ instance PluginRequestMethod Method_TextDocumentSemanticTokensFullDelta where instance PluginRequestMethod Method_TextDocumentInlayHint where combineResponses _ _ _ _ x = sconcat x +instance PluginRequestMethod Method_WorkspaceWillRenameFiles where + takeLefts :: [a |? b] -> [a] takeLefts = mapMaybe (\x -> [res | (InL res) <- Just x]) @@ -909,6 +922,8 @@ instance PluginNotificationMethod Method_WorkspaceDidChangeConfiguration where instance PluginNotificationMethod Method_Initialized where +instance PluginNotificationMethod Method_WorkspaceDidRenameFiles where + -- --------------------------------------------------------------------- -- | Methods which have a PluginMethod instance @@ -1239,6 +1254,7 @@ instance HasTracing CompletionItem instance HasTracing DocumentLink instance HasTracing InlayHint instance HasTracing WorkspaceSymbol +instance HasTracing RenameFilesParams -- --------------------------------------------------------------------- --Experimental resolve refactoring {-# NOINLINE pROCESS_ID #-} diff --git a/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal.hs b/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal.hs index dadc5503fc..cf2186b4e2 100644 --- a/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal.hs +++ b/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal.hs @@ -16,8 +16,12 @@ import qualified Data.List as List import qualified Data.Maybe as Maybe import qualified Data.Text () import qualified Data.Text as T +import Data.Text.Utf16.Rope.Mixed as Rope + +import Control.Monad.Except (runExceptT) import Development.IDE as D -import Development.IDE.Core.FileStore (getVersionedTextDoc) +import Development.IDE.Core.FileStore (getVersionedTextDoc, + getVersionedTextDocForNormalizedFilePath) import Development.IDE.Core.PluginUtils import Development.IDE.Core.Shake (restartShakeSession) import Development.IDE.Graph (Key) @@ -33,6 +37,7 @@ import Distribution.PackageDescription.Configuration (flattenPackageDe import qualified Distribution.Parsec.Position as Syntax import qualified Ide.Plugin.Cabal.CabalAdd.CodeAction as CabalAdd import qualified Ide.Plugin.Cabal.CabalAdd.Command as CabalAdd +import qualified Ide.Plugin.Cabal.CabalAdd.Rename as Rename import Ide.Plugin.Cabal.Completion.CabalFields as CabalFields import qualified Ide.Plugin.Cabal.Completion.Completer.Types as CompleterTypes import qualified Ide.Plugin.Cabal.Completion.Completions as Completions @@ -57,6 +62,11 @@ import qualified Language.LSP.VFS as VFS import qualified Text.Fuzzy.Levenshtein as Fuzzy import qualified Text.Fuzzy.Parallel as Fuzzy import Text.Regex.TDFA +import Data.Text.Encoding (encodeUtf8) +import Debug.Trace (traceShowM) +import Control.Monad.Trans.Except (ExceptT) +import qualified Development.IDE.Core.Shake as Shake +import qualified Data.Text.IO as Text data Log = LogModificationTime NormalizedFilePath FileVersion @@ -70,6 +80,9 @@ data Log | LogCompletionContext Types.Context Position | LogCompletions Types.Log | LogCabalAdd CabalAdd.Log + | LogDidRename Rename.Log + | LogShake Shake.Log + | LogSessionRestart deriving (Show) instance Pretty Log where @@ -95,6 +108,9 @@ instance Pretty Log where <+> pretty position LogCompletions logs -> pretty logs LogCabalAdd logs -> pretty logs + LogDidRename logs -> pretty logs + LogSessionRestart -> "Restarting shake session globally" + LogShake logs -> pretty logs {- | Some actions in cabal files can be triggered from haskell files. This descriptor allows us to hook into the diagnostics of haskell source files and @@ -128,6 +144,11 @@ descriptor recorder plId = , mkPluginHandler LSP.SMethod_TextDocumentCodeAction $ fieldSuggestCodeAction recorder , mkPluginHandler LSP.SMethod_TextDocumentDefinition gotoDefinition , mkPluginHandler LSP.SMethod_TextDocumentHover hover + , mkPluginHandler LSP.SMethod_WorkspaceWillRenameFiles $ + \ide _ (RenameFilesParams renames) -> do + case renames of + (fileRename:_) -> renameModuleHandler recorder ide fileRename + _ -> error "cannot handle multiple file renames" ] , pluginNotificationHandlers = mconcat @@ -165,7 +186,6 @@ descriptor recorder plId = log' = logWith recorder ruleRecorder = cmapWithPrio LogRule recorder ofInterestRecorder = cmapWithPrio LogOfInterest recorder - whenUriFile :: Uri -> (NormalizedFilePath -> IO ()) -> IO () whenUriFile uri act = whenJust (uriToFilePath uri) $ act . toNormalizedFilePath' @@ -300,6 +320,36 @@ cabalAddModuleCodeAction recorder state plId (CodeActionParams _ _ (TextDocument pure $ InL $ fmap InR actions Nothing -> pure $ InL [] +renameModuleHandler :: Recorder (WithPriority Log) -> IdeState -> FileRename -> ExceptT PluginError (HandlerM Config) (WorkspaceEdit |? Null) +renameModuleHandler recorder state (FileRename oldUri newUri) = do + caps <- lift pluginGetClientCapabilities + renameResult <- runExceptT $ do + oldHaskellFilePath <- uriToFilePathE $ Uri oldUri + newHaskellFilePath <- uriToFilePathE $ Uri newUri + mbCabalFile <- liftIO $ CabalAdd.findResponsibleCabalFile oldHaskellFilePath + case mbCabalFile of + Nothing -> pure undefined -- todo log this maybe + Just cabalFilePath -> do + (contents, fields, gpd, verTextDocId) <- runActionE "cabal-plugin.getUriContents" state $ do -- todo mv to handler + let nuri = toNormalizedUri $ filePathToUri cabalFilePath + nfp = toNormalizedFilePath cabalFilePath + mContent <- lift $ getUriContents nuri + content <- case mContent of + Just content -> pure content + Nothing -> liftIO $ Rope.fromText <$> Text.readFile cabalFilePath + verTextDocId <- lift $ getVersionedTextDocForNormalizedFilePath nfp + (fields, _) <- useWithStaleE ParseCabalFields nfp + (gpd, _) <- useWithStaleE ParseCabalFile nfp + pure (content, fields, gpd, verTextDocId) + Rename.renameHandler (cmapWithPrio LogDidRename recorder) state (caps, verTextDocId) oldHaskellFilePath newHaskellFilePath cabalFilePath (encodeUtf8 $ Rope.toText contents) fields gpd + + case renameResult of + Left err -> do + traceShowM ("BANANA", pretty err) --todo better error handling pls + pure $ InR Null + Right edit -> do + pure $ InL edit + {- | Handler for hover messages. If the cursor is hovering on a dependency, add a documentation link to that dependency. diff --git a/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/CabalAdd/CodeAction.hs b/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/CabalAdd/CodeAction.hs index d72ad290fd..e52f93df9f 100644 --- a/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/CabalAdd/CodeAction.hs +++ b/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/CabalAdd/CodeAction.hs @@ -244,7 +244,7 @@ addDependencySuggestCodeAction :: GenericPackageDescription -> IO [J.CodeAction] addDependencySuggestCodeAction plId verTxtDocId suggestions haskellFilePath cabalFilePath gpd = do - buildTargets <- liftIO $ getBuildTargets gpd cabalFilePath haskellFilePath + buildTargets <- liftIO $ getBuildTargets (flattenPackageDescription gpd) cabalFilePath haskellFilePath case buildTargets of -- If there are no build targets found, run the `cabal-add` command with default behaviour [] -> pure $ mkCodeActionForDependency cabalFilePath Nothing <$> suggestions @@ -263,17 +263,6 @@ addDependencySuggestCodeAction plId verTxtDocId suggestions haskellFilePath caba -} buildTargetToStringRepr target = render $ CabalPretty.pretty $ buildTargetComponentName target - {- | Finds the build targets that are used in `cabal-add`. - Note the unorthodox usage of `readBuildTargets`: - If the relative path to the haskell file is provided, - `readBuildTargets` will return the build targets, this - module is mentioned in (either exposed-modules or other-modules). - -} - getBuildTargets :: GenericPackageDescription -> FilePath -> FilePath -> IO [BuildTarget] - getBuildTargets gpd cabalFilePath haskellFilePath = do - let haskellFileRelativePath = makeRelative (dropFileName cabalFilePath) haskellFilePath - readBuildTargets (verboseNoStderr silent) (flattenPackageDescription gpd) [haskellFileRelativePath] - mkCodeActionForDependency :: FilePath -> Maybe String -> (T.Text, T.Text) -> J.CodeAction mkCodeActionForDependency cabalFilePath target (suggestedDep, suggestedVersion) = let @@ -296,6 +285,17 @@ addDependencySuggestCodeAction plId verTxtDocId suggestions haskellFilePath caba in J.CodeAction title (Just CodeActionKind_QuickFix) (Just []) Nothing Nothing Nothing (Just command) Nothing +{- | Finds the build targets that are used in `cabal-add`. + Note the unorthodox usage of `readBuildTargets`: + If the relative path to the haskell file is provided, + `readBuildTargets` will return the build targets, this + module is mentioned in (either exposed-modules or other-modules). +-} +getBuildTargets :: PackageDescription -> FilePath -> FilePath -> IO [BuildTarget] +getBuildTargets pd cabalFilePath haskellFilePath = do + let haskellFileRelativePath = makeRelative (dropFileName cabalFilePath) haskellFilePath + readBuildTargets (verboseNoStderr silent) pd [haskellFileRelativePath] + {- | Gives a mentioned number of @(dependency, version)@ pairs found in the "hidden package" diagnostic message. diff --git a/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/CabalAdd/Rename.hs b/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/CabalAdd/Rename.hs new file mode 100644 index 0000000000..472a8d4e28 --- /dev/null +++ b/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/CabalAdd/Rename.hs @@ -0,0 +1,309 @@ +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ViewPatterns #-} + + +module Ide.Plugin.Cabal.CabalAdd.Rename ( + renameHandler, + Log, +) +where +import Control.Applicative +import Control.Lens (re) +import Control.Lens.Getter ((^.)) +import Control.Monad (guard, (<=<)) +import qualified Control.Monad.Extra as Maybe +import Control.Monad.Trans +import Control.Monad.Trans.Except (ExceptT, throwE) +import Control.Monad.Trans.Maybe +import Data.ByteString (ByteString) +import qualified Data.Map.Strict as Map +import qualified Data.Maybe as Maybe +import qualified Data.Text as T +import qualified Data.Text.Encoding as T +import Debug.Trace (traceShowId) +import Development.IDE.Core.FileStore (getVersionedTextDocForNormalizedFilePath) +import Development.IDE.Core.PluginUtils (runActionE) +import Development.IDE.Core.PositionMapping (toCurrentRange) +import Development.IDE.Core.Rules hiding (Log) +import Development.IDE.Core.RuleTypes (GetModuleGraph (..)) +import qualified Development.IDE.Core.Shake as Shake +import qualified Development.IDE.GHC.Compat as GHC +import Development.IDE.GHC.Compat.Core +import Development.IDE.GHC.Error (realSrcSpanToRange, + srcSpanToRange) +import Development.IDE.Import.DependencyInformation (immediateReverseDependencies) +import qualified Distribution.Client.Add as Add +import Distribution.Client.Rename (RenameConfig (..), + executeRenameConfig) +import Distribution.Fields (Field) +import qualified Distribution.ModuleName as Cabal +import Distribution.PackageDescription +import Distribution.PackageDescription.Configuration (flattenPackageDescription) +import Distribution.Parsec.Position (Position) +import Distribution.Simple.BuildTarget (buildTargetComponentName) +import Ide.Logger +import Ide.Plugin.Cabal.CabalAdd.CodeAction (buildInfoToHsSourceDirs, + getBuildTargets, + mkRelativeModulePathM) +import Ide.Plugin.Cabal.Definition (lookupBuildTargetPackageDescription) +import Ide.Plugin.Error +import Ide.PluginUtils (WithDeletions (IncludeDeletions), + diffText) +import Language.LSP.Protocol.Types (ClientCapabilities, + NormalizedFilePath, + Range, + TextDocumentEdit (..), + TextEdit (..), + VersionedTextDocumentIdentifier, + WorkspaceEdit (WorkspaceEdit), + _versionedTextDocumentIdentifier, + fromNormalizedFilePath, + toNormalizedFilePath, + type (|?) (InL)) + +data Log + = LogDidRename FilePath FilePath + | LogRenameDependencies T.Text [NormalizedFilePath] + deriving (Show) + +instance Pretty Log where + pretty = \case + LogDidRename oldFp newFp -> "Received rename info from:" <+> pretty oldFp <+> "to:" <+> pretty newFp + LogRenameDependencies oldName fps -> "Rename of" <+> pretty oldName <+> "in" <+> (pretty $ map fromNormalizedFilePath fps) + +-------------------------------------------- +-- Rename module in cabal file +-------------------------------------------- + +renameHandler :: + forall m. + (MonadIO m) => + Recorder (WithPriority Log) -> + Shake.IdeState -> + (ClientCapabilities, VersionedTextDocumentIdentifier) -> + -- | the old file path, before the rename + FilePath -> + -- | the new file path, after the rename + FilePath -> + -- | the path to the cabal file, responsible for the renamed module + FilePath -> + -- | the responsible cabal file's contents + ByteString -> + -- | the responsible cabal file's fields + [Field Position] -> + GenericPackageDescription -> + ExceptT PluginError m WorkspaceEdit +renameHandler recorder ideState (caps, verTxtDocId) oldHaskellFilePath newHaskellFilePath cabalFilePath cnfOrigContents fields gpd = do + logWith recorder Info $ LogDidRename oldHaskellFilePath newHaskellFilePath + let pd = flattenPackageDescription gpd + compName <- resolveFileTargetE pd cabalFilePath oldHaskellFilePath + buildInfo <- resolveBuildInfoE pd compName + newModulePath <- toRelativeModulePathE (buildInfoToHsSourceDirs buildInfo) cabalFilePath newHaskellFilePath + oldModulePath <- toRelativeModulePathE (buildInfoToHsSourceDirs buildInfo) cabalFilePath oldHaskellFilePath + + cabalFileEdit <- applyModuleRenameToCabalFile recorder (caps, verTxtDocId) oldHaskellFilePath newHaskellFilePath cabalFilePath cnfOrigContents fields gpd + modDeclEdit <- renameModuleDeclaration recorder ideState oldHaskellFilePath newModulePath + importEdits <- applyRenameToImports recorder ideState oldModulePath newModulePath $ toNormalizedFilePath oldHaskellFilePath + pure $ traceShowId $ combineTextEdits importEdits $ combineTextEdits (traceShowId cabalFileEdit) $ traceShowId modDeclEdit + +-- | Apply rename to the given module's declaration +-- +-- Rename the module in the given file's module declaration. +-- Fails if . +renameModuleDeclaration :: MonadIO m => Recorder (WithPriority Log) -> IdeState -> FilePath -> T.Text -> ExceptT PluginError m WorkspaceEdit +renameModuleDeclaration recorder ideState oldHaskellFilePath newModulePath = do + verTextDocId <- runActionE "cabal-plugin.getUriContents" ideState $ lift $ getVersionedTextDocForNormalizedFilePath $ toNormalizedFilePath oldHaskellFilePath + rangeToRename <- maybeToExceptT PluginStaleResolve $ + MaybeT $ liftIO $ moduleNameRange ideState $ toNormalizedFilePath oldHaskellFilePath + let edit = mkTextEditInRange newModulePath verTextDocId rangeToRename + pure $ WorkspaceEdit Nothing (Just [InL edit]) Nothing + +-- | Apply rename to the given cabal file +-- +-- Replaces the module name corresponding to the old file path with the +-- module name corresponding to the new file path in the given cabal file. +-- Fails if the cabal file cannot be parsed, the file paths cannot be parsed to module names +-- or no occurence of the module can be found in the cabal file. +applyModuleRenameToCabalFile :: + forall m. + (MonadIO m) => + Recorder (WithPriority Log) -> + (ClientCapabilities, VersionedTextDocumentIdentifier) -> + -- | the old file path before the rename + FilePath -> + -- | the new file path after the rename + FilePath -> + -- | the path to the cabal file, responsible for the renamed module + FilePath -> + -- | the responsible cabal file's contents + ByteString -> + -- | the responsible cabal file's fields + [Field Position] -> + GenericPackageDescription -> + ExceptT PluginError m WorkspaceEdit +applyModuleRenameToCabalFile recorder (caps, verTxtDocId) oldHaskellFilePath newHaskellFilePath cabalFilePath cnfOrigContents fields gpd = do + let pd = flattenPackageDescription gpd + + compName <- resolveFileTargetE pd cabalFilePath oldHaskellFilePath + buildInfo <- resolveBuildInfoE pd compName + newModulePath <- toRelativeModulePathE (buildInfoToHsSourceDirs buildInfo) cabalFilePath newHaskellFilePath + oldModulePath <- toRelativeModulePathE (buildInfoToHsSourceDirs buildInfo) cabalFilePath oldHaskellFilePath + targetField <- resolveTargetFieldForComponentE pd oldModulePath compName buildInfo + + newContents <- maybeToExceptT PluginStaleResolve $ hoistMaybe $ + executeRenameConfig (Add.validateChanges gpd) (renameConfig (Right $ compName) targetField oldModulePath newModulePath) + pure $ diffText caps (verTxtDocId, T.decodeUtf8 cnfOrigContents) (T.decodeUtf8 newContents) IncludeDeletions + where + -- define renameConfig to pass to cabal-add + renameConfig compName targetField from to = RenameConfig + { cnfOrigContents = cnfOrigContents + , cnfFields = fields + , cnfComponent = compName + , cnfTargetField = targetField + , cnfRenameFrom = T.encodeUtf8 from + , cnfRenameTo = T.encodeUtf8 to + } + +-- | Apply rename to all imports of the given module +-- +-- Replaces all imports of the given old module name with the given new module name. +applyRenameToImports :: + MonadIO m => + Recorder (WithPriority Log) -> + IdeState -> + -- | The module name before the rename. + T.Text -> + -- | The new module name after the rename. + T.Text -> + -- | The old path to the renamed haskell file. + NormalizedFilePath -> + ExceptT e m WorkspaceEdit +applyRenameToImports recorder ideState oldModulePath newModulePath oldHaskellFilePath = do + moduleGraph <- runActionE "applyRenameToImports" ideState $ lift $ Shake.useNoFile_ GetModuleGraph + let invertedDepsM = immediateReverseDependencies oldHaskellFilePath moduleGraph + case invertedDepsM of + Just depFilePaths -> do + logWith recorder Debug $ LogRenameDependencies oldModulePath depFilePaths + modImportRanges <- liftIO $ Maybe.mapMaybeM (getRangesForModuleImports ideState oldModulePath) depFilePaths + let textEdits = concatMap (\(verTextDocId,ranges) -> map (mkTextEditInRange newModulePath verTextDocId) ranges) modImportRanges + pure $ WorkspaceEdit Nothing (Just $ map InL textEdits) Nothing + Nothing -> pure $ WorkspaceEdit Nothing Nothing Nothing + +mkTextEditInRange :: T.Text -> VersionedTextDocumentIdentifier -> Range -> TextDocumentEdit +mkTextEditInRange newText verTextDocId range = + TextDocumentEdit (verTextDocId ^. re _versionedTextDocumentIdentifier) $ fmap InL [TextEdit range newText] + +-- | Determine the `TargetField` of the given module path +-- +-- Takes a module path and which component the module is a part of and returns whether the module is in the +-- exposed- or the other-modules field of the component. +-- If the module is in neither of the fields, returns Nothing. +findFieldForModule :: T.Text -> ComponentName -> PackageDescription -> BuildInfo -> Maybe Add.TargetField +findFieldForModule modulePath compName pd buildInfo = + let + exposedMods = case compName of + CLibName name -> + case name of + LMainLibName -> + Maybe.maybe [] exposedModules $ library pd + LSubLibName _ -> + concat $ Maybe.concatMapM (getExposedModulesForLib compName) $ subLibraries pd + _ -> [] + in + findInExposedModules exposedMods <|> findInOtherModules (otherModules buildInfo) + where + moduleName = Cabal.fromString (T.unpack modulePath) + + findInOtherModules mods = + Add.OtherModules <$ findInModules mods + + findInExposedModules mods = + Add.ExposedModules <$ findInModules mods + + findInModules mods = + guard $ moduleName `elem` mods + + getExposedModulesForLib :: ComponentName -> Library -> Maybe [Cabal.ModuleName] + getExposedModulesForLib compName library = + case libName library of + LSubLibName lName -> + case componentNameString compName of + Just unqualCompName -> if lName == unqualCompName then Just $ exposedModules library else Nothing + _ -> Nothing + _ -> Nothing + +-- | The module declaration range of the given file path +-- +-- Inspired by `codeModuleName` in the hls-module-name-plugin. +moduleNameRange :: Shake.IdeState -> NormalizedFilePath -> IO (Maybe Range) +moduleNameRange state nfp = runMaybeT $ do + (pm, mp) <- MaybeT . runAction "ModuleName.GetParsedModule" state $ Shake.useWithStale GetParsedModule nfp + L (locA -> (RealSrcSpan l _)) _ <- MaybeT . pure . hsmodName . unLoc $ GHC.pm_parsed_source pm + range <- MaybeT . pure $ toCurrentRange mp (realSrcSpanToRange l) + pure range + +-- | Determines all ranges in the given file where the module name is imported +-- +-- Returns the identifier of the file and a list of ranges of the imports if none of the rule applications fail. +-- Otherwise will return Nothing. +getRangesForModuleImports :: IdeState -> T.Text -> NormalizedFilePath -> IO (Maybe (VersionedTextDocumentIdentifier, [Range])) +getRangesForModuleImports state moduleName nfp = runMaybeT $ do + verTextDocId <- MaybeT . fmap Just $ runAction "cabal-plugin.getUriContents" state $ getVersionedTextDocForNormalizedFilePath nfp + (pm, mp) <- MaybeT . runAction "ModuleName.GetParsedModule" state $ Shake.useWithStale GetParsedModule nfp + let allImports = hsmodImports . unLoc $ GHC.pm_parsed_source pm + modNameImports = Maybe.mapMaybe + (\imp -> GHC.getLoc (ideclName $ GHC.unLoc imp) <$ guard ((== (mkModuleName $ T.unpack moduleName)) . GHC.unLoc . ideclName $ GHC.unLoc imp)) + allImports + pure $ (verTextDocId, Maybe.mapMaybe (toCurrentRange mp <=< srcSpanToRange) modNameImports) + +combineTextEdits :: WorkspaceEdit -> WorkspaceEdit -> WorkspaceEdit +combineTextEdits (WorkspaceEdit c1 dc1 ca1) (WorkspaceEdit c2 dc2 ca2) = + WorkspaceEdit c dc ca + where + c = liftA2 (Map.unionWith (<>)) c1 c2 <|> c1 <|> c2 + dc = dc1 <> dc2 + -- We know this might result in information loss due to the monad instance of map, + -- but we do not expect our use of workspacedit combination to contain two changeAnnotations + -- for the same edit. + ca = ca1 <> ca2 + +--------------------------------------------------------- +-- Rule applications with shortcuts to plugin errors +--------------------------------------------------------- + +-- | Returns the `BuildInfo` for the given component name. +-- If the build info cannot be resolved, throws a PluginError. +resolveBuildInfoE :: Applicative m => PackageDescription -> ComponentName -> ExceptT PluginError m BuildInfo +resolveBuildInfoE pd compName = + maybeToExceptT PluginStaleResolve $ hoistMaybe $ lookupBuildTargetPackageDescription pd (Just compName) + +-- | Determines the `ComponentName` of the given file target. +-- Tries to resolve the file target's component name within the given cabal file. +-- If the component name cannot be uniquely resolved, throws a PluginError, +resolveFileTargetE :: MonadIO m => PackageDescription -> FilePath -> FilePath -> ExceptT PluginError m ComponentName +resolveFileTargetE pd cabalFilePath fileTarget = do + buildTargets <- liftIO $ getBuildTargets pd cabalFilePath fileTarget + case buildTargets of + [buildTarget] -> pure $ buildTargetComponentName buildTarget + [] -> throwE PluginStaleResolve -- todo maybe handle these two cases differently + _ -> throwE PluginStaleResolve + +-- | Takes a list of source subdirectories, a cabal source path and a haskell filepath +-- and returns a path to the module in exposed module syntax. +-- +-- The path will be relative to one of the subdirectories, in case the module is contained within one of them. +-- If no module path can be resolved, throws a PluginError. +toRelativeModulePathE :: Applicative m => [FilePath] -> FilePath -> FilePath -> ExceptT PluginError m T.Text +toRelativeModulePathE sourceDirs cabalFilePath oldHaskellFilePath = + maybeToExceptT PluginStaleResolve $ + hoistMaybe $ mkRelativeModulePathM sourceDirs cabalFilePath oldHaskellFilePath + +-- | Returns the field, a module name is contained in. +-- +-- Takes a module name and a component name and returns whether the module name is in exposed- or other-modules of that component. +-- If the module name cannot be found in either field, throws a PluginError. +resolveTargetFieldForComponentE :: Applicative m => PackageDescription -> T.Text -> ComponentName -> BuildInfo -> ExceptT PluginError m Add.TargetField +resolveTargetFieldForComponentE pd oldModulePath compName buildInfo = + maybeToExceptT PluginStaleResolve $ + hoistMaybe $ findFieldForModule oldModulePath compName pd buildInfo diff --git a/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/Completion/CabalFields.hs b/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/Completion/CabalFields.hs index b8cb7ce0d6..13a4f8320a 100644 --- a/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/Completion/CabalFields.hs +++ b/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/Completion/CabalFields.hs @@ -1,3 +1,5 @@ +{-# LANGUAGE OverloadedStrings #-} + module Ide.Plugin.Cabal.Completion.CabalFields ( findStanzaForColumn , getModulesNames @@ -28,6 +30,8 @@ import qualified Distribution.Fields as Syntax import qualified Distribution.Parsec.Position as Syntax import Ide.Plugin.Cabal.Completion.Types import qualified Language.LSP.Protocol.Types as LSP +import Distribution.Types.ComponentName (ComponentName (CLibName, CTestName, CBenchName, CExeName, CFLibName)) +import Distribution.PackageDescription (mkUnqualComponentName, LibraryName (LSubLibName)) -- ---------------------------------------------------------------- -- Cabal-syntax utilities I don't really want to write myself @@ -155,7 +159,6 @@ getOptionalSectionName (x:xs) = case x of Syntax.SecArgName _ name -> Just (T.decodeUtf8 name) _ -> getOptionalSectionName xs -type BuildTargetName = T.Text type ModuleName = T.Text -- | Given a cabal AST returns pairs of all respective target names @@ -186,18 +189,28 @@ type ModuleName = T.Text -- * @getModulesNames@ output: -- -- > [([Just "first-target", Just "second-target"], "Config")] -getModulesNames :: [Syntax.Field any] -> [([Maybe BuildTargetName], ModuleName)] +getModulesNames :: [Syntax.Field any] -> [([Maybe ComponentName], ModuleName)] getModulesNames fields = map swap $ groupSort rawModuleTargetPairs where rawModuleTargetPairs = concatMap getSectionModuleNames sections sections = getSectionsWithModules fields - getSectionModuleNames :: Syntax.Field any -> [(ModuleName, Maybe BuildTargetName)] - getSectionModuleNames (Syntax.Section _ secArgs fields) = map (, getArgsName secArgs) $ concatMap getFieldModuleNames fields + getSectionModuleNames :: Syntax.Field any -> [(ModuleName, Maybe ComponentName)] + getSectionModuleNames (Syntax.Section secName secArgs fields) = map (, getArgsName secName secArgs) $ concatMap getFieldModuleNames fields getSectionModuleNames _ = [] - getArgsName [Syntax.SecArgName _ name] = Just $ T.decodeUtf8 name - getArgsName _ = Nothing -- Can be only a main library, that has no name + getArgsName (Syntax.Name _ secName) [Syntax.SecArgName _ nameBs] = + let + name = mkUnqualComponentName $ T.unpack $ T.decodeUtf8 nameBs + in + case secName of + "library" -> Just $ CLibName $ LSubLibName name + "test-suite" -> Just $ CTestName name + "benchmark" -> Just $ CBenchName name + "executable" -> Just $ CExeName name + "foreign-library" -> Just $ CFLibName name + _ -> Nothing + getArgsName _ _ = Nothing -- Can be only a main library, that has no name -- since it's impossible to have multiple names for a build target getFieldModuleNames field@(Syntax.Field _ modules) = if getFieldName field == T.pack "exposed-modules" || diff --git a/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/Definition.hs b/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/Definition.hs index 5f85151199..e701fb45dc 100644 --- a/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/Definition.hs +++ b/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/Definition.hs @@ -45,6 +45,9 @@ import System.Directory (doesFileExist) import System.FilePath (joinPath, takeDirectory, (<.>), ()) +import Distribution.Types.ComponentName (ComponentName) +import Data.Foldable (asum) +import Distribution.Types.ComponentName (ComponentName(..)) -- | Handler for going to definitions. -- @@ -142,50 +145,48 @@ gotoModulesDefinition nfp gpd cursor fieldsOfInterest = do isModuleName (Just name) (_, moduleName) = name == moduleName isModuleName _ _ = False --- | Gives all `buildInfo`s given a target name. +-- | Gives all 'BuildInfo's given a target name. -- --- `Maybe buildTargetName` is provided, and if it's --- Nothing we assume, that it's a main library. --- Otherwise looks for the provided name. -lookupBuildTargetPackageDescription :: PackageDescription -> Maybe T.Text -> [BuildInfo] +-- Takes a @'Maybe' 'ComponentName'@ and looks for the coresponding Buildinfo if it is Just. +-- If Nothing is passed we assume that we are looking for a main library. +-- If no main library can be found, returns Nothing. +lookupBuildTargetPackageDescription :: PackageDescription -> Maybe ComponentName -> Maybe BuildInfo lookupBuildTargetPackageDescription (PackageDescription {..}) Nothing = case library of - Nothing -> [] -- Target is a main library but no main library was found - Just (Library {libBuildInfo}) -> [libBuildInfo] + Nothing -> Nothing -- Target is a main library but no main library was found + Just (Library {libBuildInfo}) -> Just libBuildInfo lookupBuildTargetPackageDescription (PackageDescription {..}) (Just buildTargetName) = - Maybe.catMaybes $ + asum $ + foldMap libraryNameLookup library : map executableNameLookup executables <> - map subLibraryNameLookup subLibraries <> + map libraryNameLookup subLibraries <> map foreignLibsNameLookup foreignLibs <> map testSuiteNameLookup testSuites <> map benchmarkNameLookup benchmarks where executableNameLookup :: Executable -> Maybe BuildInfo executableNameLookup (Executable {exeName, buildInfo}) = - if T.pack (unUnqualComponentName exeName) == buildTargetName + if CExeName exeName == buildTargetName then Just buildInfo else Nothing - subLibraryNameLookup :: Library -> Maybe BuildInfo - subLibraryNameLookup (Library {libName, libBuildInfo}) = - case libName of - (LSubLibName name) -> - if T.pack (unUnqualComponentName name) == buildTargetName - then Just libBuildInfo - else Nothing - LMainLibName -> Nothing + libraryNameLookup :: Library -> Maybe BuildInfo + libraryNameLookup (Library {libName, libBuildInfo}) = + if CLibName libName == buildTargetName + then Just libBuildInfo + else Nothing foreignLibsNameLookup :: ForeignLib -> Maybe BuildInfo foreignLibsNameLookup (ForeignLib {foreignLibName, foreignLibBuildInfo}) = - if T.pack (unUnqualComponentName foreignLibName) == buildTargetName + if CFLibName foreignLibName == buildTargetName then Just foreignLibBuildInfo else Nothing testSuiteNameLookup :: TestSuite -> Maybe BuildInfo testSuiteNameLookup (TestSuite {testName, testBuildInfo}) = - if T.pack (unUnqualComponentName testName) == buildTargetName + if CTestName testName == buildTargetName then Just testBuildInfo else Nothing benchmarkNameLookup :: Benchmark -> Maybe BuildInfo benchmarkNameLookup (Benchmark {benchmarkName, benchmarkBuildInfo}) = - if T.pack (unUnqualComponentName benchmarkName) == buildTargetName + if CBenchName benchmarkName == buildTargetName then Just benchmarkBuildInfo else Nothing diff --git a/plugins/hls-module-name-plugin/src/Ide/Plugin/ModuleName.hs b/plugins/hls-module-name-plugin/src/Ide/Plugin/ModuleName.hs index 5dc053f47d..8c32be0401 100644 --- a/plugins/hls-module-name-plugin/src/Ide/Plugin/ModuleName.hs +++ b/plugins/hls-module-name-plugin/src/Ide/Plugin/ModuleName.hs @@ -12,6 +12,7 @@ Provide CodeLenses to: -} module Ide.Plugin.ModuleName ( descriptor, + pathModuleNames, Log, ) where diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs index 0ba6bc7975..b37c763e01 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs @@ -1,241 +1,355 @@ -{-# LANGUAGE CPP #-} -{-# LANGUAGE DataKinds #-} -{-# LANGUAGE GADTs #-} -{-# LANGUAGE OverloadedLabels #-} +{-# LANGUAGE CPP #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE OverloadedLabels #-} {-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE ViewPatterns #-} {-# OPTIONS_GHC -Wno-orphans #-} module Ide.Plugin.Rename (descriptor, E.Log) where -import Control.Lens ((^.)) -import Control.Monad -import Control.Monad.Except (ExceptT, throwError) -import Control.Monad.IO.Class (MonadIO, liftIO) -import Control.Monad.Trans.Class (lift) -import Data.Either (rights) -import Data.Foldable (fold) -import Data.Generics -import Data.Hashable -import Data.HashSet (HashSet) -import qualified Data.HashSet as HS -import Data.List.NonEmpty (NonEmpty ((:|)), - groupWith) -import qualified Data.Map as M -import Data.Maybe -import Data.Mod.Word -import qualified Data.Text as T -import Development.IDE (Recorder, WithPriority, - usePropertyAction) -import Development.IDE.Core.FileStore (getVersionedTextDoc) -import Development.IDE.Core.PluginUtils -import Development.IDE.Core.RuleTypes -import Development.IDE.Core.Service -import Development.IDE.Core.Shake -import Development.IDE.GHC.Compat -import Development.IDE.GHC.Compat.ExactPrint -import Development.IDE.GHC.Error -import Development.IDE.GHC.ExactPrint -import qualified Development.IDE.GHC.ExactPrint as E -import Development.IDE.Plugin.CodeAction -import Development.IDE.Spans.AtPoint -import Development.IDE.Types.Location -import GHC.Iface.Ext.Types (HieAST (..), - HieASTs (..), - NodeOrigin (..), - SourcedNodeInfo (..)) -import GHC.Iface.Ext.Utils (generateReferencesMap) -import HieDb ((:.) (..)) -import HieDb.Query -import HieDb.Types (RefRow (refIsGenerated)) -import Ide.Plugin.Error -import Ide.Plugin.Properties -import Ide.PluginUtils -import Ide.Types -import qualified Language.LSP.Protocol.Lens as L -import Language.LSP.Protocol.Message -import Language.LSP.Protocol.Types +import Control.Lens (re, (^.)) +import Control.Monad +import Control.Monad.Except (ExceptT, throwError) +import Control.Monad.IO.Class (MonadIO, liftIO) +import Control.Monad.Trans.Class (lift) +import Control.Monad.Trans.Except (mapExceptT) +import Control.Monad.Trans.Maybe (MaybeT (..), maybeToExceptT) +import Data.Either (rights) +import Data.Foldable (fold) +import Data.Generics +import Data.HashMap.Strict qualified as HashMap +import Data.HashSet (HashSet) +import Data.HashSet qualified as HS +import Data.Hashable +import Data.List.NonEmpty ( + NonEmpty ((:|)), + groupWith, + ) +import Data.Map qualified as M +import Data.Maybe +import Data.Mod.Word +import Data.Text qualified as T +import Data.Text.Utf16.Rope.Mixed qualified as Rope +import Development.IDE (Recorder, WithPriority, getFileContents, usePropertyAction) +import Development.IDE.Core.FileStore (getVersionedTextDoc, getVersionedTextDocForNormalizedFilePath) +import Development.IDE.Core.PluginUtils +import Development.IDE.Core.PositionMapping (toCurrentRange) +import Development.IDE.Core.RuleTypes +import Development.IDE.Core.Service hiding (Log) +import Development.IDE.Core.Shake hiding (Log) +import Development.IDE.GHC.Compat +import Development.IDE.GHC.Compat.ExactPrint +import Development.IDE.GHC.Error +import Development.IDE.GHC.ExactPrint hiding (Log) +import Development.IDE.GHC.ExactPrint qualified as E +import Development.IDE.Import.DependencyInformation (DependencyInformation (depPathIdMap), pathToIdMap) +import Development.IDE.Plugin.CodeAction +import Development.IDE.Spans.AtPoint +import Development.IDE.Types.Location +import GHC.Conc (readTVarIO) +import GHC.Iface.Ext.Types ( + HieAST (..), + HieASTs (..), + NodeOrigin (..), + SourcedNodeInfo (..), + ) +import GHC.Iface.Ext.Utils (generateReferencesMap) +import HieDb ((:.) (..)) +import HieDb.Query +import HieDb.Types (RefRow (refIsGenerated)) +import Ide.Plugin.Error +import Ide.Plugin.Properties +import Ide.PluginUtils +import Ide.Types +import Language.LSP.Protocol.Lens qualified as L +import Language.LSP.Protocol.Message +import Language.LSP.Protocol.Types instance Hashable (Mod a) where hash n = hash (unMod n) +data Log + = LogExactPrint E.Log + | LogRenameModule NormalizedFilePath + deriving (Show) + descriptor :: Recorder (WithPriority E.Log) -> PluginId -> PluginDescriptor IdeState -descriptor recorder pluginId = mkExactprintPluginDescriptor recorder $ +descriptor recorder pluginId = + mkExactprintPluginDescriptor recorder $ (defaultPluginDescriptor pluginId "Provides renaming of Haskell identifiers") - { pluginHandlers = mconcat - [ mkPluginHandler SMethod_TextDocumentRename renameProvider - , mkPluginHandler SMethod_TextDocumentPrepareRename prepareRenameProvider - ] - , pluginConfigDescriptor = defaultConfigDescriptor - { configCustomConfig = mkCustomConfig properties } - } + { pluginHandlers = + mconcat + [ mkPluginHandler SMethod_TextDocumentRename renameProvider + , mkPluginHandler SMethod_TextDocumentPrepareRename prepareRenameProvider + , mkPluginHandler SMethod_WorkspaceWillRenameFiles $ moduleRenameProvider recorder + ] + , pluginConfigDescriptor = + defaultConfigDescriptor + { configCustomConfig = mkCustomConfig properties + } + } prepareRenameProvider :: PluginMethodHandler IdeState Method_TextDocumentPrepareRename prepareRenameProvider state _pluginId (PrepareRenameParams (TextDocumentIdentifier uri) pos _progressToken) = do - nfp <- getNormalizedFilePathE uri - namesUnderCursor <- getNamesAtPos state nfp pos - -- When this handler says that rename is invalid, VSCode shows "The element can't be renamed" - -- and doesn't even allow you to create full rename request. - -- This handler deliberately approximates "things that definitely can't be renamed" - -- to mean "there is no Name at given position". - -- - -- In particular it allows some cases through (e.g. cross-module renames), - -- so that the full rename handler can give more informative error about them. - let renameValid = not $ null namesUnderCursor - pure $ InL $ PrepareRenameResult $ InR $ InR $ PrepareRenameDefaultBehavior renameValid + nfp <- getNormalizedFilePathE uri + namesUnderCursor <- getNamesAtPos state nfp pos + -- When this handler says that rename is invalid, VSCode shows "The element can't be renamed" + -- and doesn't even allow you to create full rename request. + -- This handler deliberately approximates "things that definitely can't be renamed" + -- to mean "there is no Name at given position". + -- + -- In particular it allows some cases through (e.g. cross-module renames), + -- so that the full rename handler can give more informative error about them. + let + renameValid = not $ null namesUnderCursor + pure $ InL $ PrepareRenameResult $ InR $ InR $ PrepareRenameDefaultBehavior renameValid renameProvider :: PluginMethodHandler IdeState Method_TextDocumentRename renameProvider state pluginId (RenameParams _prog (TextDocumentIdentifier uri) pos newNameText) = do - nfp <- getNormalizedFilePathE uri - directOldNames <- getNamesAtPos state nfp pos - directRefs <- concat <$> mapM (refsAtName state nfp) directOldNames - - {- References in HieDB are not necessarily transitive. With `NamedFieldPuns`, we can have - indirect references through punned names. To find the transitive closure, we do a pass of - the direct references to find the references for any punned names. - See the `IndirectPuns` test for an example. -} - indirectOldNames <- concat . filter ((>1) . length) <$> - mapM (uncurry (getNamesAtPos state) <=< locToFilePos) directRefs - let oldNames = filter matchesDirect indirectOldNames ++ directOldNames - where - matchesDirect n = occNameFS (nameOccName n) `elem` directFS - directFS = map (occNameFS . nameOccName) directOldNames - - case oldNames of - -- There were no Names at given position (e.g. rename triggered within a comment or on a keyword) - [] -> throwError $ PluginInvalidParams "No symbol to rename at given position" - _ -> do - refs <- HS.fromList . concat <$> mapM (refsAtName state nfp) oldNames - - -- Validate rename - crossModuleEnabled <- liftIO $ runAction "rename: config" state $ usePropertyAction #crossModule pluginId properties - unless crossModuleEnabled $ failWhenImportOrExport state nfp refs oldNames - when (any isBuiltInSyntax oldNames) $ throwError $ PluginInternalError "Invalid rename of built-in syntax" - - -- Perform rename - let newName = mkTcOcc $ T.unpack newNameText - filesRefs = collectWith locToUri refs - getFileEdit (uri, locations) = do - verTxtDocId <- liftIO $ runAction "rename: getVersionedTextDoc" state $ getVersionedTextDoc (TextDocumentIdentifier uri) - getSrcEdit state verTxtDocId (replaceRefs newName locations) - fileEdits <- mapM getFileEdit filesRefs - pure $ InL $ fold fileEdits + nfp <- getNormalizedFilePathE uri + directOldNames <- getNamesAtPos state nfp pos + directRefs <- concat <$> mapM (refsAtName state nfp) directOldNames + + {- References in HieDB are not necessarily transitive. With `NamedFieldPuns`, we can have + indirect references through punned names. To find the transitive closure, we do a pass of + the direct references to find the references for any punned names. + See the `IndirectPuns` test for an example. -} + indirectOldNames <- + concat . filter ((> 1) . length) + <$> mapM (uncurry (getNamesAtPos state) <=< locToFilePos) directRefs + let + oldNames = filter matchesDirect indirectOldNames ++ directOldNames + where + matchesDirect n = occNameFS (nameOccName n) `elem` directFS + directFS = map (occNameFS . nameOccName) directOldNames + + case oldNames of + -- There were no Names at given position (e.g. rename triggered within a comment or on a keyword) + [] -> throwError $ PluginInvalidParams "No symbol to rename at given position" + _ -> do + refs <- HS.fromList . concat <$> mapM (refsAtName state nfp) oldNames + + -- Validate rename + crossModuleEnabled <- liftIO $ runAction "rename: config" state $ usePropertyAction #crossModule pluginId properties + unless crossModuleEnabled $ failWhenImportOrExport state nfp refs oldNames + when (any isBuiltInSyntax oldNames) $ throwError $ PluginInternalError "Invalid rename of built-in syntax" + + -- Perform rename + let + newName = mkTcOcc $ T.unpack newNameText + filesRefs = collectWith locToUri refs + getFileEdit (uri, locations) = do + verTxtDocId <- liftIO $ runAction "rename: getVersionedTextDoc" state $ getVersionedTextDoc (TextDocumentIdentifier uri) + getSrcEdit state verTxtDocId (replaceRefs newName locations) + fileEdits <- mapM getFileEdit filesRefs + pure $ InL $ fold fileEdits + +-- | Apply rename to the given module's declaration +-- +-- Rename the module in the given file's module declaration. +moduleRenameProvider :: Recorder (WithPriority Log) -> IdeState -> PluginId -> RenameFilesParams -> ExceptT PluginError (HandlerM Config) (WorkspaceEdit |? Null) +moduleRenameProvider recorder state plId (RenameFilesParams renames) = do + caps <- lift pluginGetClientCapabilities + edits <- + forM renames $ \(FileRename oldUri newUri) -> do + oldHaskellFilePath <- fmap toNormalizedFilePath $ uriToFilePathE $ Uri oldUri + newHaskellFilePath <- uriToFilePathE $ Uri newUri + knownTargets <- liftIO $ readTVarIO $ knownTargetsVar $ shakeExtras state + verTextDocId <- runActionE "cabal-plugin.getUriContents" state $ lift $ getVersionedTextDocForNormalizedFilePath oldHaskellFilePath + rangeToRename <- + maybeToExceptT PluginStaleResolve $ + MaybeT $ + liftIO $ + moduleNameRange state oldHaskellFilePath + depInfo <- runActionE "moduleRenameProvider" state $ lift $ useWithSeparateFingerprintRule_ GetModuleGraphTransDepsFingerprints GetModuleGraph oldHaskellFilePath + fpId <- + maybeToExceptT PluginStaleResolve $ + MaybeT $ + pure $ + HashMap.lookup oldHaskellFilePath $ + pathToIdMap $ + depPathIdMap depInfo + + let + edit = mkTextEditInRange (newModule) verTextDocId rangeToRename + pure undefined + pure undefined + +guessModuleName state uri = do + nfp <- getNormalizedFilePathE uri + fp <- uriToFilePathE uri + + contents <- liftIO $ runAction "ModuleName.getFileContents" state $ getFileContents nfp + let + emptyModule = maybe True (T.null . T.strip . Rope.toText) contents + + correctNames <- mapExceptT liftIO $ pathModuleNames recorder state nfp fp + logWith recorder Debug (CorrectNames correctNames) + let + bestName = minimumBy (comparing T.length) <$> NE.nonEmpty correctNames + logWith recorder Debug (BestName bestName) + + statedNameMaybe <- liftIO $ codeModuleName state nfp + logWith recorder Debug (ModuleName $ snd <$> statedNameMaybe) + case (bestName, statedNameMaybe) of + (Just bestName, Just (nameRange, statedName)) + | statedName `notElem` correctNames -> + pure [Replace uri nameRange ("Set module name to " <> bestName) bestName] + (Just bestName, Nothing) + | emptyModule -> + let + code = "module " <> bestName <> " where\n" + in + pure [Replace uri (Range (Position 0 0) (Position 0 0)) code code] + +mkTextEditInRange :: T.Text -> VersionedTextDocumentIdentifier -> Range -> TextDocumentEdit +mkTextEditInRange newText verTextDocId range = + TextDocumentEdit (verTextDocId ^. re _versionedTextDocumentIdentifier) $ fmap InL [TextEdit range newText] -- | Limit renaming across modules. failWhenImportOrExport :: - IdeState -> - NormalizedFilePath -> - HashSet Location -> - [Name] -> - ExceptT PluginError (HandlerM config) () + IdeState -> + NormalizedFilePath -> + HashSet Location -> + [Name] -> + ExceptT PluginError (HandlerM config) () failWhenImportOrExport state nfp refLocs names = do - pm <- runActionE "Rename.GetParsedModule" state - (useE GetParsedModule nfp) - let hsMod = unLoc $ pm_parsed_source pm - case (unLoc <$> hsmodName hsMod, hsmodExports hsMod) of - (mbModName, _) | not $ any (\n -> nameIsLocalOrFrom (replaceModName n mbModName) n) names - -> throwError $ PluginInternalError "Renaming of an imported name is unsupported" - (_, Just (L _ exports)) | any ((`HS.member` refLocs) . unsafeSrcSpanToLoc . getLoc) exports - -> throwError $ PluginInternalError "Renaming of an exported name is unsupported" - (Just _, Nothing) -> throwError $ PluginInternalError "Explicit export list required for renaming" - _ -> pure () + pm <- + runActionE + "Rename.GetParsedModule" + state + (useE GetParsedModule nfp) + let + hsMod = unLoc $ pm_parsed_source pm + case (unLoc <$> hsmodName hsMod, hsmodExports hsMod) of + (mbModName, _) + | not $ any (\n -> nameIsLocalOrFrom (replaceModName n mbModName) n) names -> + throwError $ PluginInternalError "Renaming of an imported name is unsupported" + (_, Just (L _ exports)) + | any ((`HS.member` refLocs) . unsafeSrcSpanToLoc . getLoc) exports -> + throwError $ PluginInternalError "Renaming of an exported name is unsupported" + (Just _, Nothing) -> throwError $ PluginInternalError "Explicit export list required for renaming" + _ -> pure () --------------------------------------------------------------------------------------------------- -- Source renaming -- | Apply a function to a `ParsedSource` for a given `Uri` to compute a `WorkspaceEdit`. getSrcEdit :: - IdeState -> - VersionedTextDocumentIdentifier -> - (ParsedSource -> ParsedSource) -> - ExceptT PluginError (HandlerM config) WorkspaceEdit + IdeState -> + VersionedTextDocumentIdentifier -> + (ParsedSource -> ParsedSource) -> + ExceptT PluginError (HandlerM config) WorkspaceEdit getSrcEdit state verTxtDocId updatePs = do - ccs <- lift pluginGetClientCapabilities - nfp <- getNormalizedFilePathE (verTxtDocId ^. L.uri) - annAst <- runActionE "Rename.GetAnnotatedParsedSource" state - (useE GetAnnotatedParsedSource nfp) - let ps = annAst - src = T.pack $ exactPrint ps - res = T.pack $ exactPrint (updatePs ps) - pure $ diffText ccs (verTxtDocId, src) res IncludeDeletions + ccs <- lift pluginGetClientCapabilities + nfp <- getNormalizedFilePathE (verTxtDocId ^. L.uri) + annAst <- + runActionE + "Rename.GetAnnotatedParsedSource" + state + (useE GetAnnotatedParsedSource nfp) + let + ps = annAst + src = T.pack $ exactPrint ps + res = T.pack $ exactPrint (updatePs ps) + pure $ diffText ccs (verTxtDocId, src) res IncludeDeletions -- | Replace names at every given `Location` (in a given `ParsedSource`) with a given new name. replaceRefs :: - OccName -> - HashSet Location -> - ParsedSource -> - ParsedSource -replaceRefs newName refs = everywhere $ + OccName -> + HashSet Location -> + ParsedSource -> + ParsedSource +replaceRefs newName refs = + everywhere $ -- there has to be a better way... - mkT (replaceLoc @AnnListItem) `extT` - -- replaceLoc @AnnList `extT` -- not needed - -- replaceLoc @AnnParen `extT` -- not needed - -- replaceLoc @AnnPragma `extT` -- not needed - -- replaceLoc @AnnContext `extT` -- not needed - -- replaceLoc @NoEpAnns `extT` -- not needed - replaceLoc @NameAnn - where - replaceLoc :: forall an. LocatedAn an RdrName -> LocatedAn an RdrName - replaceLoc (L srcSpan oldRdrName) - | isRef (locA srcSpan) = L srcSpan $ replace oldRdrName - replaceLoc lOldRdrName = lOldRdrName - replace :: RdrName -> RdrName - replace (Qual modName _) = Qual modName newName - replace _ = Unqual newName - - isRef :: SrcSpan -> Bool - isRef = (`HS.member` refs) . unsafeSrcSpanToLoc + mkT (replaceLoc @AnnListItem) + `extT` + -- replaceLoc @AnnList `extT` -- not needed + -- replaceLoc @AnnParen `extT` -- not needed + -- replaceLoc @AnnPragma `extT` -- not needed + -- replaceLoc @AnnContext `extT` -- not needed + -- replaceLoc @NoEpAnns `extT` -- not needed + replaceLoc @NameAnn + where + replaceLoc :: forall an. LocatedAn an RdrName -> LocatedAn an RdrName + replaceLoc (L srcSpan oldRdrName) + | isRef (locA srcSpan) = L srcSpan $ replace oldRdrName + replaceLoc lOldRdrName = lOldRdrName + replace :: RdrName -> RdrName + replace (Qual modName _) = Qual modName newName + replace _ = Unqual newName + + isRef :: SrcSpan -> Bool + isRef = (`HS.member` refs) . unsafeSrcSpanToLoc --------------------------------------------------------------------------------------------------- -- Reference finding -- | Note: We only find exact name occurrences (i.e. type reference "depth" is 0). refsAtName :: - MonadIO m => - IdeState -> - NormalizedFilePath -> - Name -> - ExceptT PluginError m [Location] + (MonadIO m) => + IdeState -> + NormalizedFilePath -> + Name -> + ExceptT PluginError m [Location] refsAtName state nfp name = do - ShakeExtras{withHieDb} <- liftIO $ runAction "Rename.HieDb" state getShakeExtras - ast <- handleGetHieAst state nfp - dbRefs <- case nameModule_maybe name of - Nothing -> pure [] - Just mod -> liftIO $ mapMaybe rowToLoc <$> withHieDb (\hieDb -> - -- See Note [Generated references] - filter (\(refRow HieDb.:. _) -> refIsGenerated refRow) <$> - findReferences - hieDb - True - (nameOccName name) - (Just $ moduleName mod) - (Just $ moduleUnit mod) - [fromNormalizedFilePath nfp] + ShakeExtras{withHieDb} <- liftIO $ runAction "Rename.HieDb" state getShakeExtras + ast <- handleGetHieAst state nfp + dbRefs <- case nameModule_maybe name of + Nothing -> pure [] + Just mod -> + liftIO $ + mapMaybe rowToLoc + <$> withHieDb + ( \hieDb -> + -- See Note [Generated references] + filter (\(refRow HieDb.:. _) -> refIsGenerated refRow) + <$> findReferences + hieDb + True + (nameOccName name) + (Just $ moduleName mod) + (Just $ moduleUnit mod) + [fromNormalizedFilePath nfp] ) - pure $ nameLocs name ast ++ dbRefs + pure $ nameLocs name ast ++ dbRefs nameLocs :: Name -> HieAstResult -> [Location] nameLocs name (HAR _ _ rm _ _) = - concatMap (map (realSrcSpanToLocation . fst)) - (M.lookup (Right name) rm) + concatMap + (map (realSrcSpanToLocation . fst)) + (M.lookup (Right name) rm) --------------------------------------------------------------------------------------------------- -- Util -getNamesAtPos :: MonadIO m => IdeState -> NormalizedFilePath -> Position -> ExceptT PluginError m [Name] +-- | The module declaration range of the given file path +-- +-- Inspired by `codeModuleName` in the hls-module-name-plugin. +moduleNameRange :: IdeState -> NormalizedFilePath -> IO (Maybe Range) +moduleNameRange state nfp = runMaybeT $ do + (pm, mp) <- MaybeT . runAction "ModuleName.GetParsedModule" state $ useWithStale GetParsedModule nfp + L (locA -> (RealSrcSpan l _)) _ <- MaybeT . pure . hsmodName . unLoc $ pm_parsed_source pm + range <- MaybeT . pure $ toCurrentRange mp (realSrcSpanToRange l) + pure range + +getNamesAtPos :: (MonadIO m) => IdeState -> NormalizedFilePath -> Position -> ExceptT PluginError m [Name] getNamesAtPos state nfp pos = do - HAR{hieAst} <- handleGetHieAst state nfp - pure $ getNamesAtPoint' hieAst pos + HAR{hieAst} <- handleGetHieAst state nfp + pure $ getNamesAtPoint' hieAst pos handleGetHieAst :: - MonadIO m => - IdeState -> - NormalizedFilePath -> - ExceptT PluginError m HieAstResult + (MonadIO m) => + IdeState -> + NormalizedFilePath -> + ExceptT PluginError m HieAstResult handleGetHieAst state nfp = - -- We explicitly do not want to allow a stale version here - we only want to rename if - -- the module compiles, otherwise we can't guarantee that we'll rename everything, - -- which is bad (see https://github.com/haskell/haskell-language-server/issues/3799) - fmap removeGenerated $ runActionE "Rename.GetHieAst" state $ useE GetHieAst nfp + -- We explicitly do not want to allow a stale version here - we only want to rename if + -- the module compiles, otherwise we can't guarantee that we'll rename everything, + -- which is bad (see https://github.com/haskell/haskell-language-server/issues/3799) + fmap removeGenerated $ runActionE "Rename.GetHieAst" state $ useE GetHieAst nfp {- Note [Generated references] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -246,23 +360,25 @@ To work around this, we filter out compiler-generated references. -} removeGenerated :: HieAstResult -> HieAstResult removeGenerated HAR{..} = - HAR{hieAst = sourceOnlyAsts, refMap = sourceOnlyRefMap, ..} - where - goAsts :: HieASTs a -> HieASTs a - goAsts (HieASTs asts) = HieASTs (fmap goAst asts) - - goAst :: HieAST a -> HieAST a - goAst (Node (SourcedNodeInfo sniMap) sp children) = - let sourceOnlyNodeInfos = SourcedNodeInfo $ M.delete GeneratedInfo sniMap - in Node sourceOnlyNodeInfos sp $ map goAst children - - sourceOnlyAsts = goAsts hieAst - -- Also need to regenerate the RefMap, because the one in HAR - -- is generated from HieASTs containing GeneratedInfo - sourceOnlyRefMap = generateReferencesMap $ getAsts sourceOnlyAsts + HAR{hieAst = sourceOnlyAsts, refMap = sourceOnlyRefMap, ..} + where + goAsts :: HieASTs a -> HieASTs a + goAsts (HieASTs asts) = HieASTs (fmap goAst asts) + + goAst :: HieAST a -> HieAST a + goAst (Node (SourcedNodeInfo sniMap) sp children) = + let + sourceOnlyNodeInfos = SourcedNodeInfo $ M.delete GeneratedInfo sniMap + in + Node sourceOnlyNodeInfos sp $ map goAst children + + sourceOnlyAsts = goAsts hieAst + -- Also need to regenerate the RefMap, because the one in HAR + -- is generated from HieASTs containing GeneratedInfo + sourceOnlyRefMap = generateReferencesMap $ getAsts sourceOnlyAsts collectWith :: (Hashable a, Eq b) => (a -> b) -> HashSet a -> [(b, HashSet a)] -collectWith f = map (\(a :| as) -> (f a, HS.fromList (a:as))) . groupWith f . HS.toList +collectWith f = map (\(a :| as) -> (f a, HS.fromList (a : as))) . groupWith f . HS.toList -- | A variant 'getNamesAtPoint' that does not expect a 'PositionMapping' getNamesAtPoint' :: HieASTs a -> Position -> [Name] @@ -274,21 +390,24 @@ locToUri (Location uri _) = uri unsafeSrcSpanToLoc :: SrcSpan -> Location unsafeSrcSpanToLoc srcSpan = - case srcSpanToLocation srcSpan of - Nothing -> error "Invalid conversion from UnhelpfulSpan to Location" - Just location -> location + case srcSpanToLocation srcSpan of + Nothing -> error "Invalid conversion from UnhelpfulSpan to Location" + Just location -> location -locToFilePos :: Monad m => Location -> ExceptT PluginError m (NormalizedFilePath, Position) +locToFilePos :: (Monad m) => Location -> ExceptT PluginError m (NormalizedFilePath, Position) locToFilePos (Location uri (Range pos _)) = (,pos) <$> getNormalizedFilePathE uri replaceModName :: Name -> Maybe ModuleName -> Module replaceModName name mbModName = - mkModule (moduleUnit $ nameModule name) (fromMaybe (mkModuleName "Main") mbModName) + mkModule (moduleUnit $ nameModule name) (fromMaybe (mkModuleName "Main") mbModName) --------------------------------------------------------------------------------------------------- -- Config properties :: Properties '[ 'PropertyKey "crossModule" 'TBoolean] -properties = emptyProperties - & defineBooleanProperty #crossModule - "Enable experimental cross-module renaming" False +properties = + emptyProperties + & defineBooleanProperty + #crossModule + "Enable experimental cross-module renaming" + False