From da8c02edc30c91d9478ed6f1fd2aa7da6dea0580 Mon Sep 17 00:00:00 2001 From: izuzu Date: Thu, 12 Mar 2026 01:08:29 +0800 Subject: [PATCH 01/31] Add test cases, data files --- plugins/hls-rename-plugin/test/Main.hs | 6 +++++- plugins/hls-rename-plugin/test/testdata/Foo.hs | 3 +++ .../test/testdata/QualifiedAsAlias.expected.hs | 8 ++++++++ .../hls-rename-plugin/test/testdata/QualifiedAsAlias.hs | 8 ++++++++ .../test/testdata/QualifiedAsFunction.expected.hs | 4 ++++ .../test/testdata/QualifiedAsFunction.hs | 4 ++++ plugins/hls-rename-plugin/test/testdata/hie.yaml | 3 ++- 7 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 plugins/hls-rename-plugin/test/testdata/QualifiedAsAlias.expected.hs create mode 100644 plugins/hls-rename-plugin/test/testdata/QualifiedAsAlias.hs create mode 100644 plugins/hls-rename-plugin/test/testdata/QualifiedAsFunction.expected.hs create mode 100644 plugins/hls-rename-plugin/test/testdata/QualifiedAsFunction.hs diff --git a/plugins/hls-rename-plugin/test/Main.hs b/plugins/hls-rename-plugin/test/Main.hs index ebb53dc8a4..6913532143 100644 --- a/plugins/hls-rename-plugin/test/Main.hs +++ b/plugins/hls-rename-plugin/test/Main.hs @@ -56,7 +56,11 @@ renameTests = testGroup "Identifier" rename doc (Position 4 23) "blah" , goldenWithRename "Let expression" "LetExpression" $ \doc -> rename doc (Position 5 11) "foobar" - , goldenWithRename "Qualified as" "QualifiedAs" $ \doc -> + , goldenWithRename "Qualified-as alias in import" "QualifiedAsAlias" $ \doc -> + rename doc (Position 1 24) "G" + , goldenWithRename "Qualified-as alias in use" "QualifiedAsAlias" $ \doc -> + rename doc (Position 4 6) "G" + , goldenWithRename "Qualified-as function" "QualifiedAsFunction" $ \doc -> rename doc (Position 3 10) "baz" , goldenWithRename "Qualified shadowing" "QualifiedShadowing" $ \doc -> rename doc (Position 3 12) "foobar" diff --git a/plugins/hls-rename-plugin/test/testdata/Foo.hs b/plugins/hls-rename-plugin/test/testdata/Foo.hs index c4850149b4..220abc87d8 100644 --- a/plugins/hls-rename-plugin/test/testdata/Foo.hs +++ b/plugins/hls-rename-plugin/test/testdata/Foo.hs @@ -2,3 +2,6 @@ module Foo where foo :: Int -> Int foo x = 0 + +(!) :: Int -> Int -> Int +(!) x y = 0 diff --git a/plugins/hls-rename-plugin/test/testdata/QualifiedAsAlias.expected.hs b/plugins/hls-rename-plugin/test/testdata/QualifiedAsAlias.expected.hs new file mode 100644 index 0000000000..034019788a --- /dev/null +++ b/plugins/hls-rename-plugin/test/testdata/QualifiedAsAlias.expected.hs @@ -0,0 +1,8 @@ +import Foo ((!)) +import qualified Foo as G + +bar :: Int -> Int +bar = G.foo + +baz :: Int -> Int -> Int +baz = (!) diff --git a/plugins/hls-rename-plugin/test/testdata/QualifiedAsAlias.hs b/plugins/hls-rename-plugin/test/testdata/QualifiedAsAlias.hs new file mode 100644 index 0000000000..ffba6cf2c9 --- /dev/null +++ b/plugins/hls-rename-plugin/test/testdata/QualifiedAsAlias.hs @@ -0,0 +1,8 @@ +import Foo ((!)) +import qualified Foo as F + +bar :: Int -> Int +bar = F.foo + +baz :: Int -> Int -> Int +baz = (!) diff --git a/plugins/hls-rename-plugin/test/testdata/QualifiedAsFunction.expected.hs b/plugins/hls-rename-plugin/test/testdata/QualifiedAsFunction.expected.hs new file mode 100644 index 0000000000..a864119ef2 --- /dev/null +++ b/plugins/hls-rename-plugin/test/testdata/QualifiedAsFunction.expected.hs @@ -0,0 +1,4 @@ +import qualified Foo as F + +bar :: Int -> Int +bar = F.baz diff --git a/plugins/hls-rename-plugin/test/testdata/QualifiedAsFunction.hs b/plugins/hls-rename-plugin/test/testdata/QualifiedAsFunction.hs new file mode 100644 index 0000000000..022b2f8e31 --- /dev/null +++ b/plugins/hls-rename-plugin/test/testdata/QualifiedAsFunction.hs @@ -0,0 +1,4 @@ +import qualified Foo as F + +bar :: Int -> Int +bar = F.foo diff --git a/plugins/hls-rename-plugin/test/testdata/hie.yaml b/plugins/hls-rename-plugin/test/testdata/hie.yaml index 892a7d675f..614f7e49ce 100644 --- a/plugins/hls-rename-plugin/test/testdata/hie.yaml +++ b/plugins/hls-rename-plugin/test/testdata/hie.yaml @@ -13,7 +13,8 @@ cradle: - "ImportedFunction" - "IndirectPuns" - "LetExpression" - - "QualifiedAs" + - "QualifiedAsAlias" + - "QualifiedAsFunction" - "QualifiedFunction" - "QualifiedShadowing" - "RealignDo" From 996816209c6a489524e02e68fb417b7017b44c2e Mon Sep 17 00:00:00 2001 From: izuzu Date: Thu, 12 Mar 2026 02:22:36 +0800 Subject: [PATCH 02/31] Add `NOTES.md` containing development notes --- NOTES.md | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 NOTES.md diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000000..3541c50d08 --- /dev/null +++ b/NOTES.md @@ -0,0 +1,59 @@ +# Notes + +This branch is for experimenting and attempting to implement renaming qualified-as aliases. For example: + +```haskell + +-- Before: ---------------------------- + +import qualified Data.List as L + +bar = L.take + +-- After: ----------------------------- + +import qualified Data.List as List + +bar = List.take +``` + +## AI disclosure + +The author uses generative AI (specifically, Claude Sonnet 4.6) to understand key concepts and draft code. + +*The author has reviewed and understood all AI-generated content introduced in this branch, and can personally vouch for and explain it if needed.* + +All notes generated through Claude are marked as such. + +## How renaming currently works + +When the user hovers the cursor over `L` or `L.take`, the `hls-rename-plugin` consults GHC’ AST and determines the identifier at point. An identifier is of type `Identifier`, defined as `type Identifier = Either ModuleName Name`. + +- In `import qualified Data.List as L`, `L` is considered a `ModuleName`. +- In `L.take`, the entirety of `L.take` is considered a single `Name`. The AST records the name as external, coming from the `Data.List` module (not the `L` module, because the name is resolved). + +The plugin only considers `Name` identifiers as eligible for renaming. Therefore, if the user hovers over `L` in `import qualified Data.List as L`, the plugin says “No symbol to rename at given location.” + +Also, a `Name` records the module in which the identifier is *defined*, not the module that the identifier is imported from. This can cause problems, especially for modules that re-export other modules. + +## Possible approach + +1. When the user hovers over `L.take`, use the HIE AST to get the full span of the identifier. +2. Use [`GHC.Parser`](https://hackage-content.haskell.org/package/ghc-9.12.1/docs/GHC-Parser.html) to parse the identifier into a `RdrName` of the form `Qual ModuleName OccName` and obtain the module alias as listed in the import section. +3. Use the AST to find all other external identifiers (and check using the `isExternalName` predicate). +4. Find their spans and parse these identifiers into `RdrName` values to find those with the same qualified module alias. +5. Replace the module alias in both the import statement and the identifiers. + +## Revised approach + +> From the session summary: +> +> 1. Get the parsed AST (`HsModule GhcPs`) via `GetParsedModule`. +> 2. Traverse `hsmodImports` to find the `ImportDecl` whose `ideclAs` span contains the cursor position. +> 3. Traverse all `HsVar` nodes in `hsmodDecls`, collect those with `Qual alias _` RdrNames matching the target alias — extract their `SrcSpan`s directly from the AST. +> 4. Replace the alias text in the `ideclAs` span in the import, and each collected use-site span. +> 5. Produce a `WorkspaceEdit` with all replacements. +> +> —Claude Sonnet 4.6 + +Using the parsed AST instead of the full HIE AST allows us to inspect `RdrName` identifiers, which contain unresolved import module aliases. It also turns out that this is already implemented as a rule in HLS. From 4504a10673551470ee4ed3f6b57281208fcf4a54 Mon Sep 17 00:00:00 2001 From: izuzu Date: Thu, 12 Mar 2026 03:36:36 +0800 Subject: [PATCH 03/31] Get parsed AST --- .../src/Ide/Plugin/Rename.hs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs index b84183ae70..d8d3002add 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs @@ -157,6 +157,32 @@ failWhenImportOrExport state nfp refLocs names = do (Just _, Nothing) -> throwError $ PluginInternalError "Explicit export list required for renaming" _ -> pure () +--------------------------------------------------------------------------------------------------- +-- Qualified alias renaming -- [x] AI +-- -- [x] AI +-- Step 1: fetch the parsed AST via GetParsedModule. -- [x] AI +-- -- [x] AI +-- Import aliases (e.g. `import Data.List as L`) survive only in the parsed (`GhcPs`) AST. -- [x] AI +-- They are erased during resolving, so the HIE AST cannot be used to locate or replace them. -- [x] AI +-- The helper below fetches the parsed module using `useWithStale` so it never blocks -- [x] AI +-- the UI while GHC is still loading. -- [x] AI +-- -- [x] AI +-- Steps 2-5 (finding the alias, collecting use sites, building edits) will be -- [x] AI +-- added in subsequent iterations. -- [x] AI + +-- | Fetch the parsed module for a file, accepting a possibly stale result. -- [x] AI +-- Returns @Nothing@ if the file has not yet been indexed at all. -- [x] AI +-- TODO: Handle the @Nothing@ case. +getParsedModuleStale :: -- [x] AI + MonadIO m => -- [x] AI + IdeState -> -- [x] AI + NormalizedFilePath -> -- [x] AI + m (Maybe ParsedModule) -- [x] AI +getParsedModuleStale state nfp = -- [x] AI + liftIO $ fmap fst <$> -- [x] AI + runAction "rename.getParsedModuleStale" state -- [x] AI + (useWithStale GetParsedModule nfp) -- [x] AI + --------------------------------------------------------------------------------------------------- -- Source renaming From a1927d78a6906460487fdc481fdc70326e7507ac Mon Sep 17 00:00:00 2001 From: izuzu Date: Thu, 12 Mar 2026 03:40:00 +0800 Subject: [PATCH 04/31] Find import alias declaration at point --- NOTES.md | 4 ++- .../src/Ide/Plugin/Rename.hs | 32 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/NOTES.md b/NOTES.md index 3541c50d08..4bf47eda1f 100644 --- a/NOTES.md +++ b/NOTES.md @@ -56,4 +56,6 @@ Also, a `Name` records the module in which the identifier is *defined*, not the > > —Claude Sonnet 4.6 -Using the parsed AST instead of the full HIE AST allows us to inspect `RdrName` identifiers, which contain unresolved import module aliases. It also turns out that this is already implemented as a rule in HLS. +1. Using the parsed AST instead of the full HIE AST allows us to inspect `RdrName` identifiers, which contain unresolved import module aliases. It also turns out that this is already implemented as a rule in HLS. + +2. The cursor should be inside an import statement (such as on `L` in `import Data.List as L`), not inside a use site (like `L.take`). diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs index d8d3002add..c5fce23ace 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs @@ -183,6 +183,38 @@ getParsedModuleStale state nfp = runAction "rename.getParsedModuleStale" state -- [x] AI (useWithStale GetParsedModule nfp) -- [x] AI +-- Step 2: find the import declaration whose alias span contains the cursor. -- [x] AI +-- -- [x] AI +-- We traverse `hsmodImports` looking for an @import M as Alias@ declaration -- [x] AI +-- where the cursor position falls inside the @Alias@ token. If found, we -- [x] AI +-- return the alias name and its source span for use in steps 4 and 5. -- [x] AI + +-- | Given a cursor position that falls on the @Alias@ token in an -- [x] AI +-- @import M as Alias@ declaration (not on a use site such as @Alias.foo@), -- [x] AI +-- return the alias 'ModuleName' and the 'RealSrcSpan' of that token. -- [x] AI +-- Returns 'Nothing' if no import alias covers the cursor position. -- [x] AI +-- Multiple imports of the same module with different aliases are handled -- [x] AI +-- correctly because we match on the cursor position, not the module name. -- [x] AI +findImportAliasAtPos -- [x] AI + :: Position -- [x] AI + -> [LImportDecl GhcPs] -- [x] AI + -> Maybe (ModuleName, RealSrcSpan) -- [x] AI +findImportAliasAtPos pos imports = listToMaybe -- [x] AI + [ (aliasName, rsp) -- [x] AI + | _locatedImport@(L _ decl) <- imports -- [x] AI + , Just locatedAlias <- [ideclAs decl] -- [x] AI + , let aliasName = unLoc locatedAlias -- [x] AI + , RealSrcSpan rsp _ <- [locA locatedAlias] -- [x] AI + , rangeContainsPosition (realSrcSpanToRange rsp) pos -- [x] AI + ] -- [x] AI + +-- | Check whether a 'Range' contains a 'Position' -- [x] AI +-- (inclusive start, exclusive end). -- [x] AI +rangeContainsPosition :: Range -> Position -> Bool -- [x] AI +rangeContainsPosition (Range (Position sl sc) (Position el ec)) (Position l c) -- [x] AI + = (l > sl || (l == sl && c >= sc)) -- [x] AI + && (l < el || (l == el && c < ec)) -- [x] AI + --------------------------------------------------------------------------------------------------- -- Source renaming From a0b78ea8c4ccff19c6077ab8fdb3f5fd3317da46 Mon Sep 17 00:00:00 2001 From: izuzu Date: Thu, 12 Mar 2026 05:09:40 +0800 Subject: [PATCH 05/31] Find all use sites matching target import alias --- NOTES.md | 2 ++ .../src/Ide/Plugin/Rename.hs | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/NOTES.md b/NOTES.md index 4bf47eda1f..aa9e75c2cc 100644 --- a/NOTES.md +++ b/NOTES.md @@ -59,3 +59,5 @@ Also, a `Name` records the module in which the identifier is *defined*, not the 1. Using the parsed AST instead of the full HIE AST allows us to inspect `RdrName` identifiers, which contain unresolved import module aliases. It also turns out that this is already implemented as a rule in HLS. 2. The cursor should be inside an import statement (such as on `L` in `import Data.List as L`), not inside a use site (like `L.take`). + +3. Traversing the AST is done using `listify` from `syb`. `listify` needs to be monomorphic. To apply the correct type, use the `Anno` type family. diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs index c5fce23ace..0b47ef1301 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs @@ -59,6 +59,7 @@ import Ide.Types import qualified Language.LSP.Protocol.Lens as L import Language.LSP.Protocol.Message import Language.LSP.Protocol.Types +import GHC.Parser.Annotation (SrcSpanAnnA, LocatedN) instance Hashable (Mod a) where hash n = hash (unMod n) @@ -215,6 +216,26 @@ rangeContainsPosition (Range (Position sl sc) (Position el ec)) (Position l c) = (l > sl || (l == sl && c >= sc)) -- [x] AI && (l < el || (l == el && c < ec)) -- [x] AI +-- Step 3: collect use-site spans for every `Qual oldAlias _` RdrName. -- [x] AI +-- -- [x] AI +-- We use SYB's `listify` to collect all located RdrName nodes anywhere in -- [x] AI +-- the declaration list, then filter to those whose qualifier matches the -- [x] AI +-- target alias. Each matching node contributes its RealSrcSpan. -- [x] AI + +-- | Collect the 'RealSrcSpan' of every qualified use of @oldAlias@ in the -- [x] AI +-- given declarations, e.g. every occurrence of @L.foo@, @L.bar@, etc. -- [x] AI +-- Uses SYB 'listify' to traverse the full 'GhcPs' AST. -- [x] AI +aliasUseSiteSpans -- [x] AI + :: ModuleName -- [x] AI + -> [LHsDecl GhcPs] -- [x] AI + -> [RealSrcSpan] -- [x] AI +aliasUseSiteSpans oldAlias decls = -- [x] AI + [ rsp -- [x] AI + | L (ann :: Anno RdrName) (Qual moduleAlias _) <- listify (const True) decls -- [x] AI + , moduleAlias == oldAlias -- [x] AI + , RealSrcSpan rsp _ <- [locA ann] -- [x] AI + ] -- [x] AI + --------------------------------------------------------------------------------------------------- -- Source renaming From fa8d1fb41c9f8bc832bdcf0f2e583b71d8964f68 Mon Sep 17 00:00:00 2001 From: izuzu Date: Thu, 12 Mar 2026 05:56:01 +0800 Subject: [PATCH 06/31] Build text edits for alias declaration, use sites --- NOTES.md | 2 + .../src/Ide/Plugin/Rename.hs | 41 ++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/NOTES.md b/NOTES.md index aa9e75c2cc..dc8331d2ea 100644 --- a/NOTES.md +++ b/NOTES.md @@ -61,3 +61,5 @@ Also, a `Name` records the module in which the identifier is *defined*, not the 2. The cursor should be inside an import statement (such as on `L` in `import Data.List as L`), not inside a use site (like `L.take`). 3. Traversing the AST is done using `listify` from `syb`. `listify` needs to be monomorphic. To apply the correct type, use the `Anno` type family. + +4. GHC uses 1-based positioning; LSP uses 0-based positioning. diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs index 0b47ef1301..8933c08509 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs @@ -225,17 +225,54 @@ rangeContainsPosition (Range (Position sl sc) (Position el ec)) (Position l c) -- | Collect the 'RealSrcSpan' of every qualified use of @oldAlias@ in the -- [x] AI -- given declarations, e.g. every occurrence of @L.foo@, @L.bar@, etc. -- [x] AI -- Uses SYB 'listify' to traverse the full 'GhcPs' AST. -- [x] AI -aliasUseSiteSpans -- [x] AI +importAliasUseSiteSpans -- [x] AI :: ModuleName -- [x] AI -> [LHsDecl GhcPs] -- [x] AI -> [RealSrcSpan] -- [x] AI -aliasUseSiteSpans oldAlias decls = -- [x] AI +importAliasUseSiteSpans oldAlias decls = -- [x] AI [ rsp -- [x] AI | L (ann :: Anno RdrName) (Qual moduleAlias _) <- listify (const True) decls -- [x] AI , moduleAlias == oldAlias -- [x] AI , RealSrcSpan rsp _ <- [locA ann] -- [x] AI ] -- [x] AI +-- Step 4: build TextEdits — one for the import alias declaration and one -- [x] AI +-- for each qualifier at a use site. -- [x] AI + +-- The two helpers below are kept separate -- [x] AI +-- because the span arithmetic differs: use-site spans cover the full -- [x] AI +-- qualified name (e.g. @L.foo@) so we must truncate to the qualifier width, -- [x] AI +-- whereas import spans already cover exactly the alias token. -- [x] AI + +-- | Build a 'TextEdit' that replaces the qualifier portion of a use site. -- [x] AI +-- The span covers only the alias (e.g. the @L@ in @L.foo@), not the dot -- [x] AI +-- or the following name. Column arithmetic uses GHC's 1-based source -- [x] AI +-- locations and converts to the 0-based LSP 'Position' convention. -- [x] AI +importAliasUseSiteEdit -- [x] AI + :: ModuleName -- ^ old alias, used to compute the qualifier width -- [x] AI + -> T.Text -- ^ new alias text -- [x] AI + -> RealSrcSpan -- ^ span of the full qualified name, e.g. @L.foo@ -- [x] AI + -> TextEdit -- [x] AI +importAliasUseSiteEdit oldAlias newAlias rsp = TextEdit range newAlias -- [x] AI + where -- [x] AI + start = realSrcSpanStart rsp -- [x] AI + line = fromIntegral (srcLocLine start) - 1 -- [x] AI + startCol = fromIntegral (srcLocCol start) - 1 -- [x] AI + -- The qualifier occupies exactly as many characters as the alias string. -- [x] AI + -- The dot is at startCol + width and is not included in the edit. -- [x] AI + endCol = startCol + fromIntegral (length (moduleNameString oldAlias)) -- [x] AI + range = Range (Position line startCol) (Position line endCol) -- [x] AI + +-- | Build a 'TextEdit' that replaces the alias token in an -- [x] AI +-- @import M as Alias@ declaration. -- [x] AI +-- The span is taken directly from 'ideclAs' and already covers exactly -- [x] AI +-- the alias token, so no column arithmetic is needed. -- [x] AI +importAliasDeclEdit -- [x] AI + :: T.Text -- ^ new alias text -- [x] AI + -> RealSrcSpan -- ^ span of @Alias@ in @import M as Alias@ -- [x] AI + -> TextEdit -- [x] AI +importAliasDeclEdit newAlias rsp = TextEdit (realSrcSpanToRange rsp) newAlias -- [x] AI + --------------------------------------------------------------------------------------------------- -- Source renaming From 8e28d1e1440d8ff265f5ac4855f33b8faa138963 Mon Sep 17 00:00:00 2001 From: izuzu Date: Thu, 12 Mar 2026 19:22:35 +0800 Subject: [PATCH 07/31] Build full edit; add handlers for alias renaming --- NOTES.md | 26 +++- .../src/Ide/Plugin/Rename.hs | 132 ++++++++++++++++-- 2 files changed, 147 insertions(+), 11 deletions(-) diff --git a/NOTES.md b/NOTES.md index dc8331d2ea..d5d5d4567e 100644 --- a/NOTES.md +++ b/NOTES.md @@ -54,12 +54,36 @@ Also, a `Name` records the module in which the identifier is *defined*, not the > 4. Replace the alias text in the `ideclAs` span in the import, and each collected use-site span. > 5. Produce a `WorkspaceEdit` with all replacements. > -> —Claude Sonnet 4.6 +> —Claude 1. Using the parsed AST instead of the full HIE AST allows us to inspect `RdrName` identifiers, which contain unresolved import module aliases. It also turns out that this is already implemented as a rule in HLS. 2. The cursor should be inside an import statement (such as on `L` in `import Data.List as L`), not inside a use site (like `L.take`). + TODO: Support renaming when the cursor is on the alias at a use site (such as on `L` in `L.take`). + 3. Traversing the AST is done using `listify` from `syb`. `listify` needs to be monomorphic. To apply the correct type, use the `Anno` type family. 4. GHC uses 1-based positioning; LSP uses 0-based positioning. + +5. There are three key design questions, generated by Claude: + + > Here's step 5 — assembling the `WorkspaceEdit` and wiring everything together. A few design questions to settle before I write the code: + > + > 1. **Entry point**: should alias renaming be a separate branch inside the existing `renameProvider`/`prepareRenameProvider` handlers, or a completely separate handler registered alongside them? + > + > 2. **`prepareRenameProvider`**: for an alias rename, what should the prepare response return? The default range (the alias token at cursor) and the current alias text seems right, but should it return a `PrepareRenameResult` with a `defaultBehavior` or an explicit `range` + `placeholder`? + > + > 3. **Error handling**: if `getParsedModuleStale` returns `Nothing` (e.g. file hasn't been parsed yet), should we fall through to the existing name-based rename, or fail with an explicit error message? + > + > —Claude + + The following design decisions are taken by the author: + + 1. Add a branch inside the existing `Provider` handlers, because registering multiple `renameProvider` handlers would be difficult and impractical. The new alias-renaming branch takes place first in both handlers, ahead of the existing renaming logic. + + 2. `prepareRenameProvider` returns `PrepareRenameDefaultBehavior True` if the cursor is on a renameable alias. + + TODO: In general, `PrepareRenameResult` feels underutilized. + + 3. Fail early if HLS can’t get the parsed module, instead of falling through. The existing renaming logic also needs the module to be parsed (and then typechecked) anyway. diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs index 8933c08509..27f0c79bbf 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs @@ -91,20 +91,94 @@ descriptor recorder pluginId = mkExactprintPluginDescriptor exactPrintRecorder $ 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 + -- Step 5 (design decision 1): try alias rename first; fall through to -- [x] AI + -- name-based rename if the cursor is not on an alias token. -- [x] AI + maybeParsed <- getParsedModuleStale state nfp -- [x] AI + case maybeParsed of -- [x] AI + Nothing -> throwError $ PluginInternalError -- [x] AI + "Cannot rename: HLS has not yet parsed this module. Please wait for indexing to complete and try again." -- [x] AI + Just parsed -> do -- [x] AI + let hsModule = unLoc $ pm_parsed_source parsed -- [x] AI + imports = hsmodImports hsModule -- [x] AI + case findImportAliasDeclAtPos pos imports of -- [x] AI + Just _ -> -- [x] AI + -- Step 5 (design decision 2): return defaultBehavior. -- [x] AI + -- TODO: return an explicit range and placeholder text -- [x] AI + -- (the InL and InR (InL ...) variants of -- [x] AI + -- PrepareRenameResult) so the client highlights exactly -- [x] AI + -- the alias token and pre-fills the current alias text -- [x] AI + -- in the rename box, rather than relying on -- [x] AI + -- defaultBehavior. -- [x] AI + pure $ InL $ PrepareRenameResult $ InR $ InR $ -- [x] AI + PrepareRenameDefaultBehavior True -- [x] AI + Nothing -> do -- [x] AI + -- Fall through to name-based rename. -- [x] AI + -- + -- 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. + namesUnderCursor <- getNamesAtPos state nfp pos + 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 + -- Step 5 (design decision 1): try alias rename first; fall through to -- [x] AI + -- name-based rename if the cursor is not on an alias token. -- [x] AI + maybeParsed <- getParsedModuleStale state nfp -- [x] AI + case maybeParsed of -- [x] AI + Nothing -> throwError $ PluginInternalError -- [x] AI + "Cannot rename: HLS has not yet parsed this module. Please wait for indexing to complete and try again." -- [x] AI + Just parsed -> do -- [x] AI + let hsModule = unLoc $ pm_parsed_source parsed -- [x] AI + imports = hsmodImports hsModule -- [x] AI + decls = hsmodDecls hsModule -- [x] AI + case findImportAliasDeclAtPos pos imports of -- [x] AI + Just (oldAlias, aliasSpan) -> -- [x] AI + aliasBasedRename state uri oldAlias aliasSpan decls newNameText -- [x] AI + Nothing -> -- [x] AI + -- Fall through to name-based rename. -- [x] AI + nameBasedRename state pluginId nfp pos newNameText -- [x] AI + +-- | Alias-based rename: build 'TextEdit's for the import alias declaration -- [x] AI +-- and all use sites, then wrap in a 'WorkspaceEdit'. -- [x] AI +aliasBasedRename :: -- [x] AI + MonadIO m => -- [x] AI + IdeState -> -- [x] AI + Uri -> -- [x] AI + ModuleName -> -- [x] AI + RealSrcSpan -> -- [x] AI + [LHsDecl GhcPs] -> -- [x] AI + T.Text -> -- [x] AI + ExceptT PluginError m (MessageResult Method_TextDocumentRename) -- [x] AI +aliasBasedRename state uri oldAlias aliasSpan decls newNameText = do -- [x] AI + let useSiteSpans = importAliasUseSiteSpans oldAlias decls -- [x] AI + declEdit = importAliasDeclEdit newNameText aliasSpan -- [x] AI + useEdits = map (importAliasUseSiteEdit oldAlias newNameText) useSiteSpans -- [x] AI + allEdits = declEdit : useEdits -- [x] AI + verTxtDocId <- liftIO $ runAction "rename: getVersionedTextDoc" state $ -- [x] AI + getVersionedTextDoc (TextDocumentIdentifier uri) -- [x] AI + let fileChanges = Just $ M.singleton (verTxtDocId ^. L.uri) allEdits -- [x] AI + -- TODO: Replace 'Nothing' with meaningful details for the workspace edit. + workspaceEdit = WorkspaceEdit fileChanges Nothing Nothing -- [x] AI + pure $ InL workspaceEdit -- [x] AI + +-- | Name-based rename: the original renameProvider logic, extracted so the -- [x] AI +-- alias branch can fall through to it cleanly. -- [x] AI +nameBasedRename :: -- [x] AI + IdeState -> -- [x] AI + PluginId -> -- [x] AI + NormalizedFilePath -> -- [x] AI + Position -> -- [x] AI + T.Text -> -- [x] AI + ExceptT PluginError (HandlerM config) (MessageResult Method_TextDocumentRename) -- [x] AI +nameBasedRename state pluginId nfp pos newNameText = do -- [x] AI directOldNames <- getNamesAtPos state nfp pos directRefs <- concat <$> mapM (refsAtName state nfp) directOldNames @@ -273,6 +347,44 @@ importAliasDeclEdit -> TextEdit -- [x] AI importAliasDeclEdit newAlias rsp = TextEdit (realSrcSpanToRange rsp) newAlias -- [x] AI +-- Step 5: assemble WorkspaceEdit and wire into prepareRenameProvider and -- [x] AI +-- renameProvider. -- [x] AI +-- -- [x] AI +-- Both handlers share the same structure: fetch the parsed module, check -- [x] AI +-- whether the cursor is on an alias token, and either take the alias path -- [x] AI +-- or fall through to name-based rename. -- [x] AI +-- -- [x] AI +-- In renameProvider, the alias path delegates to aliasBasedRename, which -- [x] AI +-- collects use-site spans via importAliasUseSiteSpans, builds a TextEdit for the -- [x] AI +-- import declaration via importAliasDeclEdit and one per use site via -- [x] AI +-- importAliasUseSiteEdit, and wraps them all in a WorkspaceEdit. The name-based -- [x] AI +-- fallthrough delegates to nameBasedRename. -- [x] AI +-- -- [x] AI +-- In prepareRenameProvider, the alias path returns -- [x] AI +-- PrepareRenameDefaultBehavior True if an alias is found. If not, the name-based fallthrough -- [x] AI +-- returns PrepareRenameDefaultBehavior True or False depending on whether -- [x] AI +-- getNamesAtPos finds any Names at the cursor. -- [x] AI +-- -- [x] AI +-- Design decision 1 — entry point: a branch inside the existing handlers -- [x] AI +-- rather than a separate handler. HLS only registers one rename handler -- [x] AI +-- per plugin, so a separate handler is not viable. The alias branch is -- [x] AI +-- checked first in both handlers; if the cursor is not on an alias token, -- [x] AI +-- we fall through to name-based rename. -- [x] AI +-- -- [x] AI +-- Design decision 2 — prepareRenameProvider return value: the alias branch -- [x] AI +-- returns PrepareRenameDefaultBehavior True, consistent with the existing -- [x] AI +-- handler. PrepareRenameResult also supports returning an explicit range -- [x] AI +-- and placeholder text (the InL and InR (InL ...) variants), which would -- [x] AI +-- let the client highlight exactly the alias token and pre-fill the current -- [x] AI +-- alias text in the rename box. -- [x] AI +-- TODO: use those variants for a better UX. -- [x] AI +-- -- [x] AI +-- Design decision 3 — getParsedModuleStale returns Nothing: fail early -- [x] AI +-- with an informative error. Falling through to name-based rename is not a -- [x] AI +-- real fallback since that path also requires a typechecked module and will -- [x] AI +-- fail anyway. Nothing only occurs if the file has never been successfully -- [x] AI +-- parsed. -- [x] AI + --------------------------------------------------------------------------------------------------- -- Source renaming From 0fa49a3b9f0e08467bfef56b2d4944685d04d91d Mon Sep 17 00:00:00 2001 From: izuzu Date: Thu, 12 Mar 2026 18:20:34 +0800 Subject: [PATCH 08/31] Remove redundant import; amend function, comments --- .../src/Ide/Plugin/Rename.hs | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs index 27f0c79bbf..bfcbdb0ec9 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs @@ -59,7 +59,6 @@ import Ide.Types import qualified Language.LSP.Protocol.Lens as L import Language.LSP.Protocol.Message import Language.LSP.Protocol.Types -import GHC.Parser.Annotation (SrcSpanAnnA, LocatedN) instance Hashable (Mod a) where hash n = hash (unMod n) @@ -238,16 +237,12 @@ failWhenImportOrExport state nfp refLocs names = do -- Step 1: fetch the parsed AST via GetParsedModule. -- [x] AI -- -- [x] AI -- Import aliases (e.g. `import Data.List as L`) survive only in the parsed (`GhcPs`) AST. -- [x] AI --- They are erased during resolving, so the HIE AST cannot be used to locate or replace them. -- [x] AI +-- They are erased during resolving (called the "renaming pass" within GHC), so the HIE AST cannot be used to locate or replace them. -- [x] AI -- The helper below fetches the parsed module using `useWithStale` so it never blocks -- [x] AI -- the UI while GHC is still loading. -- [x] AI --- -- [x] AI --- Steps 2-5 (finding the alias, collecting use sites, building edits) will be -- [x] AI --- added in subsequent iterations. -- [x] AI -- | Fetch the parsed module for a file, accepting a possibly stale result. -- [x] AI -- Returns @Nothing@ if the file has not yet been indexed at all. -- [x] AI --- TODO: Handle the @Nothing@ case. getParsedModuleStale :: -- [x] AI MonadIO m => -- [x] AI IdeState -> -- [x] AI @@ -270,17 +265,17 @@ getParsedModuleStale state nfp = -- Returns 'Nothing' if no import alias covers the cursor position. -- [x] AI -- Multiple imports of the same module with different aliases are handled -- [x] AI -- correctly because we match on the cursor position, not the module name. -- [x] AI -findImportAliasAtPos -- [x] AI +findImportAliasDeclAtPos -- [x] AI :: Position -- [x] AI -> [LImportDecl GhcPs] -- [x] AI -> Maybe (ModuleName, RealSrcSpan) -- [x] AI -findImportAliasAtPos pos imports = listToMaybe -- [x] AI - [ (aliasName, rsp) -- [x] AI - | _locatedImport@(L _ decl) <- imports -- [x] AI - , Just locatedAlias <- [ideclAs decl] -- [x] AI +findImportAliasDeclAtPos pos imports = listToMaybe -- [x] AI + [ (aliasName, aliasDeclSpan) -- [x] AI + | _locatedImport@(L _ decl) <- imports -- [x] AI + , Just locatedAlias <- [ideclAs decl] -- [x] AI , let aliasName = unLoc locatedAlias -- [x] AI - , RealSrcSpan rsp _ <- [locA locatedAlias] -- [x] AI - , rangeContainsPosition (realSrcSpanToRange rsp) pos -- [x] AI + , RealSrcSpan aliasDeclSpan _ <- [locA locatedAlias] -- [x] AI + , rangeContainsPosition (realSrcSpanToRange aliasDeclSpan) pos -- [x] AI ] -- [x] AI -- | Check whether a 'Range' contains a 'Position' -- [x] AI @@ -448,6 +443,7 @@ refsAtName state nfp name = do Nothing -> pure [] Just mod -> liftIO $ mapMaybe rowToLoc <$> withHieDb (\hieDb -> -- See Note [Generated references] + -- REVIEW: Is this filter supposed to keep or remove generated references? filter (\(refRow HieDb.:. _) -> refIsGenerated refRow) <$> findReferences hieDb From a72b796e786549a5522bec013c0610f9cae8dbfb Mon Sep 17 00:00:00 2001 From: izuzu Date: Fri, 13 Mar 2026 21:30:47 +0800 Subject: [PATCH 09/31] Support renaming when cursor is on alias use site --- NOTES.md | 20 +++---- .../src/Ide/Plugin/Rename.hs | 54 +++++++++++++++++-- 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/NOTES.md b/NOTES.md index d5d5d4567e..5d0df54c8e 100644 --- a/NOTES.md +++ b/NOTES.md @@ -46,11 +46,13 @@ Also, a `Name` records the module in which the identifier is *defined*, not the ## Revised approach -> From the session summary: -> > 1. Get the parsed AST (`HsModule GhcPs`) via `GetParsedModule`. -> 2. Traverse `hsmodImports` to find the `ImportDecl` whose `ideclAs` span contains the cursor position. -> 3. Traverse all `HsVar` nodes in `hsmodDecls`, collect those with `Qual alias _` RdrNames matching the target alias — extract their `SrcSpan`s directly from the AST. +> 2. Determine the alias being renamed by checking two cursor positions, in order: +> - **2a.** The cursor is on the alias token in an import declaration — traverse `hsmodImports` to find the `ImportDecl` whose `ideclAs` span contains the cursor. +> - **2b.** The cursor is on a qualifier at a use site — traverse `hsmodDecls` to find a `Qual moduleAlias _` `RdrName` whose qualifier span contains the cursor, then look up the matching `ideclAs` in `hsmodImports`. +> +> Both yield `(ModuleName, RealSrcSpan)`: the alias name and its span in the import declaration. +> 3. Traverse all `LocatedN RdrName` nodes in `hsmodDecls` via SYB `listify`, collect those with `Qual alias _` matching the target alias — extract their `RealSrcSpan`s from the annotation. > 4. Replace the alias text in the `ideclAs` span in the import, and each collected use-site span. > 5. Produce a `WorkspaceEdit` with all replacements. > @@ -58,9 +60,7 @@ Also, a `Name` records the module in which the identifier is *defined*, not the 1. Using the parsed AST instead of the full HIE AST allows us to inspect `RdrName` identifiers, which contain unresolved import module aliases. It also turns out that this is already implemented as a rule in HLS. -2. The cursor should be inside an import statement (such as on `L` in `import Data.List as L`), not inside a use site (like `L.take`). - - TODO: Support renaming when the cursor is on the alias at a use site (such as on `L` in `L.take`). +2. The cursor can be on either an import alias declaration (such as `Ls` in `import Data.List as Ls`) or a use site (such as `Ls` in `Ls.take`). 3. Traversing the AST is done using `listify` from `syb`. `listify` needs to be monomorphic. To apply the correct type, use the `Anno` type family. @@ -79,11 +79,11 @@ Also, a `Name` records the module in which the identifier is *defined*, not the > —Claude The following design decisions are taken by the author: - + 1. Add a branch inside the existing `Provider` handlers, because registering multiple `renameProvider` handlers would be difficult and impractical. The new alias-renaming branch takes place first in both handlers, ahead of the existing renaming logic. - + 2. `prepareRenameProvider` returns `PrepareRenameDefaultBehavior True` if the cursor is on a renameable alias. - + TODO: In general, `PrepareRenameResult` feels underutilized. 3. Fail early if HLS can’t get the parsed module, instead of falling through. The existing renaming logic also needs the module to be parsed (and then typechecked) anyway. diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs index bfcbdb0ec9..66de7f2af2 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs @@ -9,6 +9,7 @@ module Ide.Plugin.Rename (descriptor, Log) where +import Control.Applicative (Alternative ((<|>))) import Control.Lens ((^.)) import Control.Monad import Control.Monad.Except (ExceptT, throwError) @@ -99,7 +100,9 @@ prepareRenameProvider state _pluginId (PrepareRenameParams (TextDocumentIdentifi Just parsed -> do -- [x] AI let hsModule = unLoc $ pm_parsed_source parsed -- [x] AI imports = hsmodImports hsModule -- [x] AI - case findImportAliasDeclAtPos pos imports of -- [x] AI + decls = hsmodDecls hsModule -- [x] AI + case (findImportAliasDeclAtPos pos imports -- [x] AI + <|> findImportAliasUseAtPos pos decls imports) of -- [x] AI Just _ -> -- [x] AI -- Step 5 (design decision 2): return defaultBehavior. -- [x] AI -- TODO: return an explicit range and placeholder text -- [x] AI @@ -138,7 +141,8 @@ renameProvider state pluginId (RenameParams _prog (TextDocumentIdentifier uri) p let hsModule = unLoc $ pm_parsed_source parsed -- [x] AI imports = hsmodImports hsModule -- [x] AI decls = hsmodDecls hsModule -- [x] AI - case findImportAliasDeclAtPos pos imports of -- [x] AI + case (findImportAliasDeclAtPos pos imports -- [ ] AI + <|> findImportAliasUseAtPos pos decls imports) of -- [ ] AI Just (oldAlias, aliasSpan) -> -- [x] AI aliasBasedRename state uri oldAlias aliasSpan decls newNameText -- [x] AI Nothing -> -- [x] AI @@ -255,9 +259,14 @@ getParsedModuleStale state nfp = -- Step 2: find the import declaration whose alias span contains the cursor. -- [x] AI -- -- [x] AI --- We traverse `hsmodImports` looking for an @import M as Alias@ declaration -- [x] AI --- where the cursor position falls inside the @Alias@ token. If found, we -- [x] AI --- return the alias name and its source span for use in steps 4 and 5. -- [x] AI +-- We support two types of cursor positions: -- [x] AI +-- 2a. The cursor is on the alias token in an import declaration, -- [x] AI +-- e.g. on `Ls` in `import Data.List as Ls`. -- [x] AI +-- 2b. The cursor is on the qualifier of a use site, -- [x] AI +-- e.g. on `Ls` in `Ls.sort`. -- [x] AI +-- Both return the same (ModuleName, RealSrcSpan): the alias name and its -- [x] AI +-- span in the import declaration, for use in steps 3-5. -- [x] AI +-- The callers try 2a first via (<|>), then 2b. -- [x] AI -- | Given a cursor position that falls on the @Alias@ token in an -- [x] AI -- @import M as Alias@ declaration (not on a use site such as @Alias.foo@), -- [x] AI @@ -278,6 +287,41 @@ findImportAliasDeclAtPos pos imports = listToMaybe , rangeContainsPosition (realSrcSpanToRange aliasDeclSpan) pos -- [x] AI ] -- [x] AI +-- | Given a cursor position that falls on the qualifier of a use site -- [x] AI +-- (e.g. on @Ls@ in @Ls.sort@), find the import declaration that introduced -- [x] AI +-- that alias and return the alias 'ModuleName' and the 'RealSrcSpan' of the -- [x] AI +-- alias token in that declaration (e.g. @Ls@ in @import Data.List as Ls@). -- [x] AI +-- Returns 'Nothing' if the cursor is not on a qualifier that corresponds to -- [x] AI +-- any import alias. -- [x] AI +findImportAliasUseAtPos -- [x] AI + :: Position -- [x] AI + -> [LHsDecl GhcPs] -- [x] AI + -> [LImportDecl GhcPs] -- [x] AI + -> Maybe (ModuleName, RealSrcSpan) -- [x] AI +findImportAliasUseAtPos pos decls imports = do -- [x] AI + aliasAtPos <- listToMaybe -- [x] AI + [ moduleAlias -- [x] AI + | L (ann :: Anno RdrName) (Qual moduleAlias _) <- listify (const True) decls -- [x] AI + , RealSrcSpan useSiteSpan _ <- [locA ann] -- [x] AI + , rangeContainsPosition (realSrcSpanToRange useSiteSpan) pos -- [x] AI + , let aliasLength = fromIntegral (length (moduleNameString moduleAlias)) -- [x] AI + start = realSrcSpanStart useSiteSpan -- [x] AI + line = fromIntegral (srcLocLine start) -- [x] AI + startColumn = fromIntegral (srcLocCol start) -- [x] AI + aliasRange = Range -- [x] AI + (Position (line - 1) (startColumn - 1)) -- [x] AI + (Position (line - 1) (startColumn - 1 + aliasLength)) -- [x] AI + , rangeContainsPosition aliasRange pos -- [x] AI + ] -- [x] AI + listToMaybe -- [x] AI + [ (aliasName, aliasDeclSpan) -- [x] AI + | _locatedImport@(L _ decl) <- imports -- [x] AI + , Just locatedAlias <- [ideclAs decl] -- [x] AI + , let aliasName = unLoc locatedAlias -- [x] AI + , aliasName == aliasAtPos -- [x] AI + , RealSrcSpan aliasDeclSpan _ <- [locA locatedAlias] -- [x] AI + ] -- [x] AI + -- | Check whether a 'Range' contains a 'Position' -- [x] AI -- (inclusive start, exclusive end). -- [x] AI rangeContainsPosition :: Range -> Position -> Bool -- [x] AI From c07286e44425b7e8da20d3fe74284302351f38bd Mon Sep 17 00:00:00 2001 From: izuzu Date: Fri, 13 Mar 2026 21:46:38 +0800 Subject: [PATCH 10/31] Remove unused test data files --- .../hls-rename-plugin/test/testdata/QualifiedAs.expected.hs | 4 ---- plugins/hls-rename-plugin/test/testdata/QualifiedAs.hs | 4 ---- 2 files changed, 8 deletions(-) delete mode 100644 plugins/hls-rename-plugin/test/testdata/QualifiedAs.expected.hs delete mode 100644 plugins/hls-rename-plugin/test/testdata/QualifiedAs.hs diff --git a/plugins/hls-rename-plugin/test/testdata/QualifiedAs.expected.hs b/plugins/hls-rename-plugin/test/testdata/QualifiedAs.expected.hs deleted file mode 100644 index a864119ef2..0000000000 --- a/plugins/hls-rename-plugin/test/testdata/QualifiedAs.expected.hs +++ /dev/null @@ -1,4 +0,0 @@ -import qualified Foo as F - -bar :: Int -> Int -bar = F.baz diff --git a/plugins/hls-rename-plugin/test/testdata/QualifiedAs.hs b/plugins/hls-rename-plugin/test/testdata/QualifiedAs.hs deleted file mode 100644 index 022b2f8e31..0000000000 --- a/plugins/hls-rename-plugin/test/testdata/QualifiedAs.hs +++ /dev/null @@ -1,4 +0,0 @@ -import qualified Foo as F - -bar :: Int -> Int -bar = F.foo From 0587f0b46703cc2dec96bf6e6f1b9e24d894a8e9 Mon Sep 17 00:00:00 2001 From: izuzu Date: Wed, 18 Mar 2026 18:07:05 +0800 Subject: [PATCH 11/31] Simplify finding `RdrName` nodes in parsed source --- .../hls-rename-plugin/src/Ide/Plugin/Rename.hs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs index 66de7f2af2..ee8dcf0562 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs @@ -449,17 +449,14 @@ replaceRefs :: 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 +replaceRefs newName refs = everywhere (mkT replaceLoc) where - replaceLoc :: forall an. LocatedAn an RdrName -> LocatedAn an RdrName + -- See Note [XRec and SrcSpans in the AST] in Language.Haskell.Syntax.Extension + -- See Note [XRec and Anno in the AST] in GHC.Parser.Annotation + -- GHC recommends using 'XRec' (available since 9.4.8 or earlier) to + -- get the right annotation type for a given target type. + -- XRec (GhcPass 'Parsed) RdrName = GenLocated (Anno RdrName) RdrName + replaceLoc :: XRec (GhcPass 'Parsed) RdrName -> XRec (GhcPass 'Parsed) RdrName replaceLoc (L srcSpan oldRdrName) | isRef (locA srcSpan) = L srcSpan $ replace oldRdrName replaceLoc lOldRdrName = lOldRdrName From 14ffa2f649407a5456aafdd5c9d28c29ffe27466 Mon Sep 17 00:00:00 2001 From: izuzu Date: Wed, 18 Mar 2026 21:58:32 +0800 Subject: [PATCH 12/31] Amend approach to handle imports with same alias --- NOTES.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/NOTES.md b/NOTES.md index 5d0df54c8e..816f0a5fbd 100644 --- a/NOTES.md +++ b/NOTES.md @@ -49,7 +49,7 @@ Also, a `Name` records the module in which the identifier is *defined*, not the > 1. Get the parsed AST (`HsModule GhcPs`) via `GetParsedModule`. > 2. Determine the alias being renamed by checking two cursor positions, in order: > - **2a.** The cursor is on the alias token in an import declaration — traverse `hsmodImports` to find the `ImportDecl` whose `ideclAs` span contains the cursor. -> - **2b.** The cursor is on a qualifier at a use site — traverse `hsmodDecls` to find a `Qual moduleAlias _` `RdrName` whose qualifier span contains the cursor, then look up the matching `ideclAs` in `hsmodImports`. +> - **2b.** The cursor is on a qualifier at a use site — traverse `hsmodDecls` to find a `Qual moduleAlias _` `RdrName` whose qualifier span contains the cursor, then look up the matching `ideclAs` in `hsmodImports`. If multiple imports share the same alias, fall back to the renamed AST via a fresh `TypeCheck` to disambiguate. > > Both yield `(ModuleName, RealSrcSpan)`: the alias name and its span in the import declaration. > 3. Traverse all `LocatedN RdrName` nodes in `hsmodDecls` via SYB `listify`, collect those with `Qual alias _` matching the target alias — extract their `RealSrcSpan`s from the annotation. @@ -62,6 +62,17 @@ Also, a `Name` records the module in which the identifier is *defined*, not the 2. The cursor can be on either an import alias declaration (such as `Ls` in `import Data.List as Ls`) or a use site (such as `Ls` in `Ls.take`). + - FIXME: Targeting `L` in `L.take` in this example causes the wrong alias to be renamed: + + ``` haskell + import Control.Lens as L + import Data.List as L + + f = L.take + ``` + + Relevant link: [GHC wiki page](https://gitlab.haskell.org/ghc/ghc/-/wikis/commentary/compiler/renamer?version_id=9dccaa3e023565a2ef5091b4a08da847872714ff) + 3. Traversing the AST is done using `listify` from `syb`. `listify` needs to be monomorphic. To apply the correct type, use the `Anno` type family. 4. GHC uses 1-based positioning; LSP uses 0-based positioning. From 257c3086cdba5f54e2f948623449d981430d2fb7 Mon Sep 17 00:00:00 2001 From: izuzu Date: Thu, 19 Mar 2026 00:15:20 +0800 Subject: [PATCH 13/31] Add new helper module for alias renaming --- haskell-language-server.cabal | 1 + .../src/Ide/Plugin/Rename/ImportAlias.hs | 142 ++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs diff --git a/haskell-language-server.cabal b/haskell-language-server.cabal index 9ebdc2fb60..0be9dd607f 100644 --- a/haskell-language-server.cabal +++ b/haskell-language-server.cabal @@ -595,6 +595,7 @@ library hls-rename-plugin buildable: False exposed-modules: Ide.Plugin.Rename + Ide.Plugin.Rename.ImportAlias Ide.Plugin.Rename.ModuleName hs-source-dirs: plugins/hls-rename-plugin/src build-depends: diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs new file mode 100644 index 0000000000..6897cfefb5 --- /dev/null +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs @@ -0,0 +1,142 @@ +{-# LANGUAGE DataKinds #-} + +{-| Logic for renaming qualified import aliases. + +For example: + +> -- Before: --------------------------- +> import qualified Data.List as L +> bar = L.take +> -- After: ---------------------------- +> import qualified Data.List as List +> bar = List.take + +The basic approach is this: + +1. Get the parsed AST and see if there is an import alias at the cursor. +2. Check whether multiple modules are imported using the same alias. +3. Rename entities throughout the AST: + * If only one module uses the alias, perform renaming using 'RdrName' and + the parsed AST. + * If multiple modules use the alias, perform alias resolution and renaming + using 'GlobalRdrEnv' and the typechecked AST. + +The common case, with each alias corresponding to one module, should be very +fast, even if the user renames multiple aliases in quick succession. +-} +module Ide.Plugin.Rename.ImportAlias -- [ ] AI + ( getParsedModuleStale -- [ ] AI + , findImportAliasDeclAtPos -- [ ] AI + , findImportAliasUseAtPos -- [ ] AI + , resolveAliasAtPos -- [ ] AI + , aliasBasedRename -- [ ] AI + , importAliasUseSiteSpans -- [ ] AI + , importAliasUseSiteEdit -- [ ] AI + , importAliasDeclEdit -- [ ] AI + , rangeContainsPosition -- [ ] AI + ) where -- [ ] AI + +import Control.Lens ((^.)) +import Control.Monad.Except (ExceptT, throwError) +import Control.Monad.IO.Class (MonadIO, liftIO) +import Data.Generics +import qualified Data.Map as M +import Data.Maybe +import qualified Data.Text as T +import Development.IDE.Core.FileStore (getVersionedTextDoc) +import Development.IDE.Core.PluginUtils +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.Types.Location +import Ide.Plugin.Error +import qualified Language.LSP.Protocol.Lens as L +import Language.LSP.Protocol.Message +import Language.LSP.Protocol.Types + +-- | Fetch the parsed module, accepting a stale result. -- [ ] AI +-- Returns @Nothing@ if the file has never been indexed. -- [ ] AI +getParsedModuleStale -- [ ] AI + :: MonadIO m -- [ ] AI + => IdeState -- [ ] AI + -> NormalizedFilePath -- [ ] AI + -> m (Maybe ParsedModule) -- [ ] AI +getParsedModuleStale = undefined -- [ ] AI + +-- | Find the module name for the import alias declaration at the cursor. -- [ ] AI +-- The cursor must be on the @Alias@ token in @import Module as Alias@. -- [ ] AI +findImportAliasDeclAtPos -- [ ] AI + :: Position -- [ ] AI + -> [LImportDecl GhcPs] -- [ ] AI + -> Maybe (ModuleName, RealSrcSpan) -- [ ] AI +findImportAliasDeclAtPos = undefined -- [ ] AI + +-- | Find all module names for the qualifier at the cursor. -- [ ] AI +-- The cursor must be on the @Alias@ part of @Alias.name@. -- [ ] AI +-- Returns multiple module names if they are imported using the same alias. -- [ ] AI +findImportAliasUseAtPos -- [ ] AI + :: Position -- [ ] AI + -> [LHsDecl GhcPs] -- [ ] AI + -> [LImportDecl GhcPs] -- [ ] AI + -> [(ModuleName, RealSrcSpan)] -- [ ] AI +findImportAliasUseAtPos = undefined -- [ ] AI + +-- | Determine the alias being renamed at the cursor position. The cursor may be -- [ ] AI +-- on the alias token in an import declaration or on a qualifier at a use site. -- [ ] AI +-- If multiple imports share the same alias, falls back to the typechecked -- [ ] AI +-- module's 'GlobalRdrEnv' to disambiguate. -- [ ] AI +-- Returns 'Nothing' if the cursor is not on any alias declaration or qualifier. -- [ ] AI +resolveAliasAtPos -- [ ] AI + :: MonadIO m -- [ ] AI + => IdeState -- [ ] AI + -> NormalizedFilePath -- [ ] AI + -> Position -- [ ] AI + -> [LHsDecl GhcPs] -- [ ] AI + -> [LImportDecl GhcPs] -- [ ] AI + -> ExceptT PluginError m (Maybe (ModuleName, RealSrcSpan)) -- [ ] AI +resolveAliasAtPos = undefined -- [ ] AI + +-- | Build a 'WorkspaceEdit' renaming an import alias and all its use sites. -- [ ] AI +aliasBasedRename -- [ ] AI + :: MonadIO m -- [ ] AI + => IdeState -- [ ] AI + -> NormalizedFilePath -- [ ] AI + -> Uri -- [ ] AI + -> ModuleName -- [ ] AI + -> RealSrcSpan -- [ ] AI + -> [LImportDecl GhcPs] -- [ ] AI + -> [LHsDecl GhcPs] -- [ ] AI + -> T.Text -- [ ] AI + -> ExceptT PluginError m (MessageResult Method_TextDocumentRename) -- [ ] AI +aliasBasedRename = undefined -- [ ] AI + +-- | Collect 'RealSrcSpan's of every use of @oldAlias.name@ in the declarations. -- [ ] AI +-- Does not disambiguate if multiple imports share the alias. -- [ ] AI +importAliasUseSiteSpans -- [ ] AI + :: ModuleName -- [ ] AI + -> [LHsDecl GhcPs] -- [ ] AI + -> [RealSrcSpan] -- [ ] AI +importAliasUseSiteSpans = undefined -- [ ] AI + +-- | Build a 'TextEdit' replacing the qualifier part in a qualified name (like -- [ ] AI +-- @L@ in @L.foo@). -- [ ] AI +-- The span covers only the alias, not the dot. -- [ ] AI +importAliasUseSiteEdit -- [ ] AI + :: ModuleName -- ^ Old alias, used to compute the qualifier width -- [ ] AI + -> T.Text -- ^ New alias text -- [ ] AI + -> RealSrcSpan -- ^ Span of the full qualified name, such as @L.foo@ -- [ ] AI + -> TextEdit -- [ ] AI +importAliasUseSiteEdit = undefined -- [ ] AI + +-- | Build a 'TextEdit' replacing the alias token in an import declaration (like -- [ ] AI +-- from @import Module as Alias@ to @import Module as NewAlias@). -- [ ] AI +importAliasDeclEdit -- [ ] AI + :: T.Text -- ^ New alias text -- [ ] AI + -> RealSrcSpan -- ^ Span of @Alias@ in @import M as Alias@ -- [ ] AI + -> TextEdit -- [ ] AI +importAliasDeclEdit = undefined -- [ ] AI + +-- | Check whether a range contains a position (inclusive start, exclusive end). -- [ ] AI +rangeContainsPosition :: Range -> Position -> Bool -- [ ] AI +rangeContainsPosition = undefined -- [ ] AI From 325119a3d1fbdd49f3b7d64f9ceddecf11bacdca Mon Sep 17 00:00:00 2001 From: izuzu Date: Thu, 19 Mar 2026 13:55:31 +0800 Subject: [PATCH 14/31] Fill in function implementations --- .../src/Ide/Plugin/Rename/ImportAlias.hs | 355 +++++++++++++----- 1 file changed, 259 insertions(+), 96 deletions(-) diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs index 6897cfefb5..4749a177e9 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs @@ -24,17 +24,17 @@ The basic approach is this: The common case, with each alias corresponding to one module, should be very fast, even if the user renames multiple aliases in quick succession. -} -module Ide.Plugin.Rename.ImportAlias -- [ ] AI - ( getParsedModuleStale -- [ ] AI - , findImportAliasDeclAtPos -- [ ] AI - , findImportAliasUseAtPos -- [ ] AI - , resolveAliasAtPos -- [ ] AI - , aliasBasedRename -- [ ] AI - , importAliasUseSiteSpans -- [ ] AI - , importAliasUseSiteEdit -- [ ] AI - , importAliasDeclEdit -- [ ] AI - , rangeContainsPosition -- [ ] AI - ) where -- [ ] AI +module Ide.Plugin.Rename.ImportAlias -- [ ] AI + ( getParsedModuleStale -- [ ] AI + , findImportAliasDeclAtPos -- [ ] AI + , findImportAliasUseAtPos -- [ ] AI + , resolveAliasAtPos -- [ ] AI + , aliasBasedRename -- [ ] AI + , importAliasUseSiteSpans -- [ ] AI + , importAliasUseSiteEdit -- [ ] AI + , importAliasDeclEdit -- [ ] AI + , rangeContainsPosition -- [ ] AI + ) where -- [ ] AI import Control.Lens ((^.)) import Control.Monad.Except (ExceptT, throwError) @@ -55,88 +55,251 @@ import qualified Language.LSP.Protocol.Lens as L import Language.LSP.Protocol.Message import Language.LSP.Protocol.Types --- | Fetch the parsed module, accepting a stale result. -- [ ] AI --- Returns @Nothing@ if the file has never been indexed. -- [ ] AI -getParsedModuleStale -- [ ] AI - :: MonadIO m -- [ ] AI - => IdeState -- [ ] AI - -> NormalizedFilePath -- [ ] AI - -> m (Maybe ParsedModule) -- [ ] AI -getParsedModuleStale = undefined -- [ ] AI - --- | Find the module name for the import alias declaration at the cursor. -- [ ] AI --- The cursor must be on the @Alias@ token in @import Module as Alias@. -- [ ] AI -findImportAliasDeclAtPos -- [ ] AI - :: Position -- [ ] AI - -> [LImportDecl GhcPs] -- [ ] AI - -> Maybe (ModuleName, RealSrcSpan) -- [ ] AI -findImportAliasDeclAtPos = undefined -- [ ] AI - --- | Find all module names for the qualifier at the cursor. -- [ ] AI --- The cursor must be on the @Alias@ part of @Alias.name@. -- [ ] AI --- Returns multiple module names if they are imported using the same alias. -- [ ] AI -findImportAliasUseAtPos -- [ ] AI - :: Position -- [ ] AI - -> [LHsDecl GhcPs] -- [ ] AI - -> [LImportDecl GhcPs] -- [ ] AI - -> [(ModuleName, RealSrcSpan)] -- [ ] AI -findImportAliasUseAtPos = undefined -- [ ] AI - --- | Determine the alias being renamed at the cursor position. The cursor may be -- [ ] AI --- on the alias token in an import declaration or on a qualifier at a use site. -- [ ] AI --- If multiple imports share the same alias, falls back to the typechecked -- [ ] AI --- module's 'GlobalRdrEnv' to disambiguate. -- [ ] AI --- Returns 'Nothing' if the cursor is not on any alias declaration or qualifier. -- [ ] AI -resolveAliasAtPos -- [ ] AI - :: MonadIO m -- [ ] AI - => IdeState -- [ ] AI - -> NormalizedFilePath -- [ ] AI - -> Position -- [ ] AI - -> [LHsDecl GhcPs] -- [ ] AI - -> [LImportDecl GhcPs] -- [ ] AI - -> ExceptT PluginError m (Maybe (ModuleName, RealSrcSpan)) -- [ ] AI -resolveAliasAtPos = undefined -- [ ] AI - --- | Build a 'WorkspaceEdit' renaming an import alias and all its use sites. -- [ ] AI -aliasBasedRename -- [ ] AI - :: MonadIO m -- [ ] AI - => IdeState -- [ ] AI - -> NormalizedFilePath -- [ ] AI - -> Uri -- [ ] AI - -> ModuleName -- [ ] AI - -> RealSrcSpan -- [ ] AI - -> [LImportDecl GhcPs] -- [ ] AI - -> [LHsDecl GhcPs] -- [ ] AI - -> T.Text -- [ ] AI - -> ExceptT PluginError m (MessageResult Method_TextDocumentRename) -- [ ] AI -aliasBasedRename = undefined -- [ ] AI - --- | Collect 'RealSrcSpan's of every use of @oldAlias.name@ in the declarations. -- [ ] AI --- Does not disambiguate if multiple imports share the alias. -- [ ] AI -importAliasUseSiteSpans -- [ ] AI - :: ModuleName -- [ ] AI - -> [LHsDecl GhcPs] -- [ ] AI - -> [RealSrcSpan] -- [ ] AI -importAliasUseSiteSpans = undefined -- [ ] AI - --- | Build a 'TextEdit' replacing the qualifier part in a qualified name (like -- [ ] AI --- @L@ in @L.foo@). -- [ ] AI --- The span covers only the alias, not the dot. -- [ ] AI -importAliasUseSiteEdit -- [ ] AI - :: ModuleName -- ^ Old alias, used to compute the qualifier width -- [ ] AI - -> T.Text -- ^ New alias text -- [ ] AI - -> RealSrcSpan -- ^ Span of the full qualified name, such as @L.foo@ -- [ ] AI - -> TextEdit -- [ ] AI -importAliasUseSiteEdit = undefined -- [ ] AI - --- | Build a 'TextEdit' replacing the alias token in an import declaration (like -- [ ] AI --- from @import Module as Alias@ to @import Module as NewAlias@). -- [ ] AI -importAliasDeclEdit -- [ ] AI - :: T.Text -- ^ New alias text -- [ ] AI - -> RealSrcSpan -- ^ Span of @Alias@ in @import M as Alias@ -- [ ] AI - -> TextEdit -- [ ] AI -importAliasDeclEdit = undefined -- [ ] AI - --- | Check whether a range contains a position (inclusive start, exclusive end). -- [ ] AI -rangeContainsPosition :: Range -> Position -> Bool -- [ ] AI -rangeContainsPosition = undefined -- [ ] AI +-- | Fetch the parsed module for a file, accepting a stale result. +-- Returns @Nothing@ if the file has never been indexed. +getParsedModuleStale -- [ ] AI + :: MonadIO m -- [ ] AI + => IdeState -- [ ] AI + -> NormalizedFilePath -- [ ] AI + -> m (Maybe ParsedModule) -- [ ] AI +getParsedModuleStale state nfp = -- [ ] AI + liftIO $ fmap fst <$> -- [ ] AI + runAction "rename.getParsedModuleStale" state -- [ ] AI + (useWithStale GetParsedModule nfp) -- [ ] AI + +-- | Find the module name and alias declaration span at the cursor. +-- The cursor must be on the @Alias@ token in @import Module as Alias@, in which +-- case the function returns @Module@ and the span for @Alias@. +findImportAliasDeclAtPos -- [ ] AI + :: Position -- [ ] AI + -> [LImportDecl GhcPs] -- [ ] AI + -> Maybe (ModuleName, RealSrcSpan) -- [ ] AI +findImportAliasDeclAtPos pos imports = listToMaybe -- [ ] AI + [ (aliasName, aliasDeclSpan) -- [ ] AI + | _locatedImport@(L _ decl) <- imports -- [ ] AI + , Just locatedAlias <- [ideclAs decl] -- [ ] AI + , let aliasName = unLoc locatedAlias -- [ ] AI + , RealSrcSpan aliasDeclSpan _ <- [locA locatedAlias] -- [ ] AI + , rangeContainsPosition (realSrcSpanToRange aliasDeclSpan) pos -- [ ] AI + ] -- [ ] AI + +-- | Find all module names and alias declaration spans for the qualifier at the +-- cursor. The cursor must be on the @Alias@ part of @Alias.name@. +-- Returns multiple module names if they share the same alias. +findImportAliasUseAtPos -- [ ] AI + :: Position -- [ ] AI + -> [LHsDecl GhcPs] -- [ ] AI + -> [LImportDecl GhcPs] -- [ ] AI + -> [(ModuleName, RealSrcSpan)] -- [ ] AI +findImportAliasUseAtPos pos decls imports = -- [ ] AI + case listToMaybe -- [ ] AI + [ moduleAlias -- [ ] AI + | L (ann :: Anno RdrName) (Qual moduleAlias _) <- listify (const True) decls -- [ ] AI + , RealSrcSpan useSiteSpan _ <- [locA ann] -- [ ] AI + , rangeContainsPosition (realSrcSpanToRange useSiteSpan) pos -- [ ] AI + , let aliasLength = fromIntegral (length (moduleNameString moduleAlias)) -- [ ] AI + start = realSrcSpanStart useSiteSpan -- [ ] AI + line = fromIntegral (srcLocLine start) -- [ ] AI + startColumn = fromIntegral (srcLocCol start) -- [ ] AI + aliasRange = Range -- [ ] AI + (Position (line - 1) (startColumn - 1)) -- [ ] AI + (Position (line - 1) (startColumn - 1 + aliasLength)) -- [ ] AI + , rangeContainsPosition aliasRange pos -- [ ] AI + ] of -- [ ] AI + Nothing -> -- [ ] AI + [] -- [ ] AI + Just aliasAtPos -> -- [ ] AI + [ (aliasName, aliasDeclSpan) -- [ ] AI + | _locatedImport@(L _ decl) <- imports -- [ ] AI + , Just locatedAlias <- [ideclAs decl] -- [ ] AI + , let aliasName = unLoc locatedAlias -- [ ] AI + , aliasName == aliasAtPos -- [ ] AI + , RealSrcSpan aliasDeclSpan _ <- [locA locatedAlias] -- [ ] AI + ] -- [ ] AI + +-- | Return the module name and declaration span for the alias being renamed at +-- the cursor. The cursor may be on the alias token in an import declaration or +-- on a qualifier at a use site. If multiple imports share the same alias, falls +-- back to the typechecked module's 'GlobalRdrEnv' to disambiguate. +-- Returns @Nothing@ if the cursor is not on any alias declaration or qualifier. +resolveAliasAtPos -- [ ] AI + :: MonadIO m -- [ ] AI + => IdeState -- [ ] AI + -> NormalizedFilePath -- [ ] AI + -> Position -- [ ] AI + -> [LHsDecl GhcPs] -- [ ] AI + -> [LImportDecl GhcPs] -- [ ] AI + -> ExceptT PluginError m (Maybe (ModuleName, RealSrcSpan)) -- [ ] AI +resolveAliasAtPos state nfp pos decls imports = -- [ ] AI + case findImportAliasDeclAtPos pos imports of -- [ ] AI + Just result -> pure (Just result) -- [ ] AI + Nothing -> case findImportAliasUseAtPos pos decls imports of -- [ ] AI + [] -> pure Nothing -- [ ] AI + [result] -> pure (Just result) -- [ ] AI + _many -> disambiguateAliasAtPos state nfp pos imports -- [ ] AI + +-- | Build a 'WorkspaceEdit' renaming an import alias and all its use sites. +aliasBasedRename -- [ ] AI + :: MonadIO m -- [ ] AI + => IdeState -- [ ] AI + -> NormalizedFilePath -- [ ] AI + -> Uri -- [ ] AI + -> ModuleName -- [ ] AI + -> RealSrcSpan -- [ ] AI + -> [LImportDecl GhcPs] -- [ ] AI + -> [LHsDecl GhcPs] -- [ ] AI + -> T.Text -- [ ] AI + -> ExceptT PluginError m (MessageResult Method_TextDocumentRename) -- [ ] AI +aliasBasedRename state nfp uri oldAlias aliasSpan imports decls newNameText = do -- [ ] AI + let duplicateAlias = -- [ ] AI + length [ () -- [ ] AI + | L _ decl <- imports -- [ ] AI + , Just locAlias <- [ideclAs decl] -- [ ] AI + , unLoc locAlias == oldAlias -- [ ] AI + ] > 1 -- [ ] AI + useSiteSpans <- -- [ ] AI + if duplicateAlias -- [ ] AI + then do -- [ ] AI + actualMod <- resolveActualMod aliasSpan imports -- [ ] AI + case actualMod of -- [ ] AI + Nothing -> throwError $ PluginInternalError -- [ ] AI + "Cannot rename: could not resolve which module this alias belongs to." -- [ ] AI + Just modName -> -- [ ] AI + importAliasUseSiteSpansDisambiguated state nfp oldAlias modName decls -- [ ] AI + else pure $ importAliasUseSiteSpans oldAlias decls -- [ ] AI + let declEdit = importAliasDeclEdit newNameText aliasSpan -- [ ] AI + useEdits = map (importAliasUseSiteEdit oldAlias newNameText) useSiteSpans -- [ ] AI + allEdits = declEdit : useEdits -- [ ] AI + verTxtDocId <- liftIO $ runAction "rename.getVersionedTextDoc" state $ -- [ ] AI + getVersionedTextDoc (TextDocumentIdentifier uri) -- [ ] AI + let fileChanges = Just $ M.singleton (verTxtDocId ^. L.uri) allEdits -- [ ] AI + -- TODO: Replace 'Nothing' with meaningful details for the workspace edit. + workspaceEdit = WorkspaceEdit fileChanges Nothing Nothing -- [ ] AI + pure $ InL workspaceEdit -- [ ] AI + +-- | Collect the 'RealSrcSpan' of every qualified use of @oldAlias@, such as in +-- @oldAlias.foo@, @oldAlias.bar@, and so on. +-- Does not disambiguate if multiple imports share the alias. +importAliasUseSiteSpans -- [ ] AI + :: ModuleName -- [ ] AI + -> [LHsDecl GhcPs] -- [ ] AI + -> [RealSrcSpan] -- [ ] AI +importAliasUseSiteSpans oldAlias decls = -- [ ] AI + [ fullNameSpan -- [ ] AI + | L (ann :: Anno RdrName) (Qual moduleAlias _) <- listify (const True) decls -- [ ] AI + , moduleAlias == oldAlias -- [ ] AI + , RealSrcSpan fullNameSpan _ <- [locA ann] -- [ ] AI + ] -- [ ] AI + +-- | Build a 'TextEdit' replacing the qualifier part in a qualified name (like +-- from @Alias.name@ to @NewAlias.name@). +-- (NOTE: GHC uses 1-based positioning; LSP uses 0-based.) +importAliasUseSiteEdit -- [ ] AI + :: ModuleName -- ^ old alias, used to compute the qualifier width -- [ ] AI + -> T.Text -- ^ new alias text -- [ ] AI + -> RealSrcSpan -- ^ span of the full qualified name, such as @Alias.name@ -- [ ] AI + -> TextEdit -- [ ] AI +importAliasUseSiteEdit oldAlias newAlias fullNameSpan = TextEdit range newAlias -- [ ] AI + where -- [ ] AI + start = realSrcSpanStart fullNameSpan -- [ ] AI + line = fromIntegral (srcLocLine start) - 1 -- [ ] AI + startCol = fromIntegral (srcLocCol start) - 1 -- [ ] AI + endCol = startCol + fromIntegral (length (moduleNameString oldAlias)) -- [ ] AI + range = Range (Position line startCol) (Position line endCol) -- [ ] AI + +-- | Build a 'TextEdit' replacing the alias token in an import declaration (like +-- from @import Module as Alias@ to @import Module as NewAlias@). +importAliasDeclEdit -- [ ] AI + :: T.Text -- ^ new alias text -- [ ] AI + -> RealSrcSpan -- ^ span of @Alias@ in @import Module as Alias@ -- [ ] AI + -> TextEdit -- [ ] AI +importAliasDeclEdit newAlias rsp = TextEdit (realSrcSpanToRange rsp) newAlias -- [ ] AI + +-- | Check whether a range contains a position (inclusive start, exclusive end). +rangeContainsPosition :: Range -> Position -> Bool -- [ ] AI +rangeContainsPosition (Range (Position sl sc) (Position el ec)) (Position l c) -- [ ] AI + = (l > sl || (l == sl && c >= sc)) -- [ ] AI + && (l < el || (l == el && c < ec)) -- [ ] AI + +--------------------------------------------------------------------------------------------------- +-- Internal helpers + +-- | Resolve an ambiguous alias use site by consulting the typechecked -- [ ] AI +-- module's 'GlobalRdrEnv'. Used when multiple imports share the same alias. -- [ ] AI +-- The caller is responsible for providing the names under the cursor. -- [ ] AI +-- TODO: Rename it to @disambiguateAliasUseAtPos@. +disambiguateAliasAtPos -- [ ] AI + :: MonadIO m -- [ ] AI + => IdeState -- [ ] AI + -> NormalizedFilePath -- [ ] AI + -> [Name] -- ^ names under the cursor, from 'getNamesAtPos' -- [ ] AI + -> [LImportDecl GhcPs] -- [ ] AI + -> ExceptT PluginError m (Maybe (ModuleName, RealSrcSpan)) -- [ ] AI +disambiguateAliasAtPos state nfp namesAtCursor imports = do -- [ ] AI + tcModule <- runActionE "rename.disambiguateAlias" state (useE TypeCheck nfp) -- [ ] AI + let rdrEnv = tcg_rdr_env (tmrTypechecked tcModule) -- [ ] AI + pure $ listToMaybe $ do -- [ ] AI + name <- namesAtCursor -- [ ] AI + gre <- maybeToList (lookupGRE_Name rdrEnv name) -- [ ] AI + impSpec <- gre_imp gre -- [ ] AI + let declSpec = is_decl impSpec -- [ ] AI + actualMod = is_mod declSpec -- [ ] AI + aliasName = is_as declSpec -- [ ] AI + L _ decl <- imports -- [ ] AI + guard (unLoc (ideclName decl) == actualMod) -- [ ] AI + Just locatedAlias <- [ideclAs decl] -- [ ] AI + guard (unLoc locatedAlias == aliasName) -- [ ] AI + RealSrcSpan aliasDeclSpan _ <- [locA locatedAlias] -- [ ] AI + pure (aliasName, aliasDeclSpan) -- [ ] AI + +-- | Identify the actual module behind an alias by matching the alias -- [ ] AI +-- declaration span against the import list. Used in 'aliasBasedRename' -- [ ] AI +-- to find which module owns the alias when multiple imports share it. -- [ ] AI +resolveActualMod -- [ ] AI + :: RealSrcSpan -- ^ span of the alias token in the import declaration -- [ ] AI + -> [LImportDecl GhcPs] -- [ ] AI + -> Maybe ModuleName -- [ ] AI +resolveActualMod aliasSpan imports = listToMaybe -- [ ] AI + [ unLoc (ideclName decl) -- [ ] AI + | L _ decl <- imports -- [ ] AI + , Just locatedAlias <- [ideclAs decl] -- [ ] AI + , RealSrcSpan declSpan _ <- [locA locatedAlias] -- [ ] AI + , declSpan == aliasSpan -- [ ] AI + ] -- [ ] AI + +-- | Like 'importAliasUseSiteSpans' but filters to use sites that resolve -- [ ] AI +-- to names from @actualMod@, using the typechecked module's 'GlobalRdrEnv'. -- [ ] AI +-- Used when multiple imports share the same alias. -- [ ] AI +importAliasUseSiteSpansDisambiguated -- [ ] AI + :: MonadIO m -- [ ] AI + => IdeState -- [ ] AI + -> NormalizedFilePath -- [ ] AI + -> ModuleName -- ^ alias, e.g. @L@ -- [ ] AI + -> ModuleName -- ^ actual module, e.g. @Control.Lens@ -- [ ] AI + -> [LHsDecl GhcPs] -- [ ] AI + -> ExceptT PluginError m [RealSrcSpan] -- [ ] AI +importAliasUseSiteSpansDisambiguated state nfp oldAlias actualMod decls = do -- [ ] AI + tcModule <- runActionE "rename.useSiteSpans" state (useE TypeCheck nfp) -- [ ] AI + let rdrEnv = tcg_rdr_env (tmrTypechecked tcModule) -- [ ] AI + allSpans = importAliasUseSiteSpansWithOcc oldAlias decls -- [ ] AI + pure -- [ ] AI + [ rsp -- [ ] AI + | (occName, rsp) <- allSpans -- [ ] AI + , gre <- maybeToList -- [ ] AI + (lookupGRE_RdrName (Qual oldAlias occName) rdrEnv) -- [ ] AI + , impSpec <- gre_imp gre -- [ ] AI + , is_mod (is_decl impSpec) == actualMod -- [ ] AI + ] -- [ ] AI + +-- | Like 'importAliasUseSiteSpans' but also returns the 'OccName' of each -- [ ] AI +-- use, needed for 'GlobalRdrEnv' lookup in the disambiguated path. -- [ ] AI +importAliasUseSiteSpansWithOcc -- [ ] AI + :: ModuleName -- [ ] AI + -> [LHsDecl GhcPs] -- [ ] AI + -> [(OccName, RealSrcSpan)] -- [ ] AI +importAliasUseSiteSpansWithOcc oldAlias decls = -- [ ] AI + [ (occName, rsp) -- [ ] AI + | L (ann :: Anno RdrName) (Qual moduleAlias occName) <- listify (const True) decls -- [ ] AI + , moduleAlias == oldAlias -- [ ] AI + , RealSrcSpan rsp _ <- [locA ann] -- [ ] AI + ] -- [ ] AI From 1e41e9630a5d1214d1c50f4c75595b6d8462e160 Mon Sep 17 00:00:00 2001 From: izuzu Date: Thu, 19 Mar 2026 15:25:40 +0800 Subject: [PATCH 15/31] Add record type storing import alias information --- NOTES.md | 4 +- .../src/Ide/Plugin/Rename/ImportAlias.hs | 164 +++++++++--------- 2 files changed, 81 insertions(+), 87 deletions(-) diff --git a/NOTES.md b/NOTES.md index 816f0a5fbd..528ba6dea1 100644 --- a/NOTES.md +++ b/NOTES.md @@ -49,7 +49,9 @@ Also, a `Name` records the module in which the identifier is *defined*, not the > 1. Get the parsed AST (`HsModule GhcPs`) via `GetParsedModule`. > 2. Determine the alias being renamed by checking two cursor positions, in order: > - **2a.** The cursor is on the alias token in an import declaration — traverse `hsmodImports` to find the `ImportDecl` whose `ideclAs` span contains the cursor. -> - **2b.** The cursor is on a qualifier at a use site — traverse `hsmodDecls` to find a `Qual moduleAlias _` `RdrName` whose qualifier span contains the cursor, then look up the matching `ideclAs` in `hsmodImports`. If multiple imports share the same alias, fall back to the renamed AST via a fresh `TypeCheck` to disambiguate. +> - **2b.** The cursor is on a qualifier at a use site — traverse `hsmodDecls` to find a `Qual moduleAlias _` `RdrName` whose qualifier span contains the cursor, then look up the matching `ideclAs` in `hsmodImports`. +> +> If multiple imports share the same alias, fall back to the renamed AST via a fresh `TypeCheck` to disambiguate. > > Both yield `(ModuleName, RealSrcSpan)`: the alias name and its span in the import declaration. > 3. Traverse all `LocatedN RdrName` nodes in `hsmodDecls` via SYB `listify`, collect those with `Qual alias _` matching the target alias — extract their `RealSrcSpan`s from the annotation. diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs index 4749a177e9..877aa96a9e 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs @@ -26,6 +26,7 @@ fast, even if the user renames multiple aliases in quick succession. -} module Ide.Plugin.Rename.ImportAlias -- [ ] AI ( getParsedModuleStale -- [ ] AI + , ImportAlias (..) -- [ ] AI , findImportAliasDeclAtPos -- [ ] AI , findImportAliasUseAtPos -- [ ] AI , resolveAliasAtPos -- [ ] AI @@ -37,12 +38,14 @@ module Ide.Plugin.Rename.ImportAlias ) where -- [ ] AI import Control.Lens ((^.)) +import Control.Monad (guard) import Control.Monad.Except (ExceptT, throwError) import Control.Monad.IO.Class (MonadIO, liftIO) import Data.Generics import qualified Data.Map as M import Data.Maybe import qualified Data.Text as T +import Development.IDE (realSrcSpanToRange) import Development.IDE.Core.FileStore (getVersionedTextDoc) import Development.IDE.Core.PluginUtils import Development.IDE.Core.RuleTypes @@ -55,6 +58,15 @@ import qualified Language.LSP.Protocol.Lens as L import Language.LSP.Protocol.Message import Language.LSP.Protocol.Types +-- | The module name, alias name, and declaration span for an import alias. +-- For example, @import Data.List as L@ corresponds to +-- @ImportAlias "Data.List" "L" @. +data ImportAlias = ImportAlias + { aliasModuleName :: ModuleName + , aliasName :: ModuleName + , aliasDeclSpan :: RealSrcSpan + } + -- | Fetch the parsed module for a file, accepting a stale result. -- Returns @Nothing@ if the file has never been indexed. getParsedModuleStale -- [ ] AI @@ -67,54 +79,54 @@ getParsedModuleStale state nfp = runAction "rename.getParsedModuleStale" state -- [ ] AI (useWithStale GetParsedModule nfp) -- [ ] AI --- | Find the module name and alias declaration span at the cursor. --- The cursor must be on the @Alias@ token in @import Module as Alias@, in which --- case the function returns @Module@ and the span for @Alias@. +-- | Find the 'ImportAlias' for the alias declaration at the cursor, such as +-- @Alias@ in @import Module as Alias@. findImportAliasDeclAtPos -- [ ] AI :: Position -- [ ] AI -> [LImportDecl GhcPs] -- [ ] AI - -> Maybe (ModuleName, RealSrcSpan) -- [ ] AI + -> Maybe ImportAlias -- [ ] AI findImportAliasDeclAtPos pos imports = listToMaybe -- [ ] AI - [ (aliasName, aliasDeclSpan) -- [ ] AI + [ ImportAlias {aliasModuleName, aliasName, aliasDeclSpan} -- [ ] AI | _locatedImport@(L _ decl) <- imports -- [ ] AI , Just locatedAlias <- [ideclAs decl] -- [ ] AI , let aliasName = unLoc locatedAlias -- [ ] AI , RealSrcSpan aliasDeclSpan _ <- [locA locatedAlias] -- [ ] AI , rangeContainsPosition (realSrcSpanToRange aliasDeclSpan) pos -- [ ] AI + , let aliasModuleName = unLoc (ideclName decl) -- [ ] AI ] -- [ ] AI --- | Find all module names and alias declaration spans for the qualifier at the --- cursor. The cursor must be on the @Alias@ part of @Alias.name@. --- Returns multiple module names if they share the same alias. +-- | Find the 'ImportAlias' matching the name qualifier at the cursor, such as +-- @Alias@ in @Alias.name@. +-- Returns multiple values if multiple modules share the same alias. findImportAliasUseAtPos -- [ ] AI :: Position -- [ ] AI -> [LHsDecl GhcPs] -- [ ] AI -> [LImportDecl GhcPs] -- [ ] AI - -> [(ModuleName, RealSrcSpan)] -- [ ] AI + -> [ImportAlias] -- [ ] AI findImportAliasUseAtPos pos decls imports = -- [ ] AI case listToMaybe -- [ ] AI - [ moduleAlias -- [ ] AI - | L (ann :: Anno RdrName) (Qual moduleAlias _) <- listify (const True) decls -- [ ] AI + [ qualifier -- [ ] AI + | L (ann :: Anno RdrName) (Qual qualifier _) <- listify (const True) decls -- [ ] AI , RealSrcSpan useSiteSpan _ <- [locA ann] -- [ ] AI , rangeContainsPosition (realSrcSpanToRange useSiteSpan) pos -- [ ] AI - , let aliasLength = fromIntegral (length (moduleNameString moduleAlias)) -- [ ] AI - start = realSrcSpanStart useSiteSpan -- [ ] AI - line = fromIntegral (srcLocLine start) -- [ ] AI - startColumn = fromIntegral (srcLocCol start) -- [ ] AI - aliasRange = Range -- [ ] AI + , let qualifierLength = fromIntegral (length (moduleNameString qualifier)) -- [ ] AI + start = realSrcSpanStart useSiteSpan -- [ ] AI + line = fromIntegral (srcLocLine start) -- [ ] AI + startColumn = fromIntegral (srcLocCol start) -- [ ] AI + qualifierRange = Range -- [ ] AI (Position (line - 1) (startColumn - 1)) -- [ ] AI - (Position (line - 1) (startColumn - 1 + aliasLength)) -- [ ] AI - , rangeContainsPosition aliasRange pos -- [ ] AI + (Position (line - 1) (startColumn - 1 + qualifierLength)) -- [ ] AI + , rangeContainsPosition qualifierRange pos -- [ ] AI ] of -- [ ] AI - Nothing -> -- [ ] AI - [] -- [ ] AI - Just aliasAtPos -> -- [ ] AI - [ (aliasName, aliasDeclSpan) -- [ ] AI - | _locatedImport@(L _ decl) <- imports -- [ ] AI - , Just locatedAlias <- [ideclAs decl] -- [ ] AI + Nothing -> [] -- [ ] AI + Just qualifierAtPos -> -- [ ] AI + [ ImportAlias {aliasModuleName, aliasName, aliasDeclSpan} -- [ ] AI + | _locatedImport@(L _ decl) <- imports -- [ ] AI + , Just locatedAlias <- [ideclAs decl] -- [ ] AI , let aliasName = unLoc locatedAlias -- [ ] AI - , aliasName == aliasAtPos -- [ ] AI - , RealSrcSpan aliasDeclSpan _ <- [locA locatedAlias] -- [ ] AI + , aliasName == qualifierAtPos -- [ ] AI + , RealSrcSpan aliasDeclSpan _ <- [locA locatedAlias] -- [ ] AI + , let aliasModuleName = unLoc (ideclName decl) ] -- [ ] AI -- | Return the module name and declaration span for the alias being renamed at @@ -129,7 +141,7 @@ resolveAliasAtPos -> Position -- [ ] AI -> [LHsDecl GhcPs] -- [ ] AI -> [LImportDecl GhcPs] -- [ ] AI - -> ExceptT PluginError m (Maybe (ModuleName, RealSrcSpan)) -- [ ] AI + -> ExceptT PluginError m (Maybe ImportAlias) -- [ ] AI resolveAliasAtPos state nfp pos decls imports = -- [ ] AI case findImportAliasDeclAtPos pos imports of -- [ ] AI Just result -> pure (Just result) -- [ ] AI @@ -144,35 +156,30 @@ aliasBasedRename => IdeState -- [ ] AI -> NormalizedFilePath -- [ ] AI -> Uri -- [ ] AI - -> ModuleName -- [ ] AI - -> RealSrcSpan -- [ ] AI + -> ImportAlias -- [ ] AI -> [LImportDecl GhcPs] -- [ ] AI -> [LHsDecl GhcPs] -- [ ] AI -> T.Text -- [ ] AI -> ExceptT PluginError m (MessageResult Method_TextDocumentRename) -- [ ] AI -aliasBasedRename state nfp uri oldAlias aliasSpan imports decls newNameText = do -- [ ] AI - let duplicateAlias = -- [ ] AI +aliasBasedRename state nfp uri importAlias imports decls newNameText = do -- [ ] AI + let oldAlias = aliasName importAlias -- [ ] AI + declSpan = aliasDeclSpan importAlias -- [ ] AI + duplicateAlias = -- [ ] AI length [ () -- [ ] AI - | L _ decl <- imports -- [ ] AI + | L _ decl <- imports -- [ ] AI , Just locAlias <- [ideclAs decl] -- [ ] AI , unLoc locAlias == oldAlias -- [ ] AI ] > 1 -- [ ] AI useSiteSpans <- -- [ ] AI if duplicateAlias -- [ ] AI - then do -- [ ] AI - actualMod <- resolveActualMod aliasSpan imports -- [ ] AI - case actualMod of -- [ ] AI - Nothing -> throwError $ PluginInternalError -- [ ] AI - "Cannot rename: could not resolve which module this alias belongs to." -- [ ] AI - Just modName -> -- [ ] AI - importAliasUseSiteSpansDisambiguated state nfp oldAlias modName decls -- [ ] AI - else pure $ importAliasUseSiteSpans oldAlias decls -- [ ] AI - let declEdit = importAliasDeclEdit newNameText aliasSpan -- [ ] AI - useEdits = map (importAliasUseSiteEdit oldAlias newNameText) useSiteSpans -- [ ] AI - allEdits = declEdit : useEdits -- [ ] AI + then importAliasUseSiteSpansDisambiguated state nfp importAlias decls -- [ ] AI + else pure $ importAliasUseSiteSpans importAlias decls -- [ ] AI + let declEdit = importAliasDeclEdit newNameText declSpan -- [ ] AI + useEdits = map (importAliasUseSiteEdit oldAlias newNameText) useSiteSpans -- [ ] AI + allEdits = declEdit : useEdits -- [ ] AI verTxtDocId <- liftIO $ runAction "rename.getVersionedTextDoc" state $ -- [ ] AI getVersionedTextDoc (TextDocumentIdentifier uri) -- [ ] AI - let fileChanges = Just $ M.singleton (verTxtDocId ^. L.uri) allEdits -- [ ] AI + let fileChanges = Just $ M.singleton (verTxtDocId ^. L.uri) allEdits -- [ ] AI -- TODO: Replace 'Nothing' with meaningful details for the workspace edit. workspaceEdit = WorkspaceEdit fileChanges Nothing Nothing -- [ ] AI pure $ InL workspaceEdit -- [ ] AI @@ -181,13 +188,13 @@ aliasBasedRename state nfp uri oldAlias aliasSpan imports decls newNameText = do -- @oldAlias.foo@, @oldAlias.bar@, and so on. -- Does not disambiguate if multiple imports share the alias. importAliasUseSiteSpans -- [ ] AI - :: ModuleName -- [ ] AI + :: ImportAlias -- [ ] AI -> [LHsDecl GhcPs] -- [ ] AI -> [RealSrcSpan] -- [ ] AI -importAliasUseSiteSpans oldAlias decls = -- [ ] AI +importAliasUseSiteSpans importAlias decls = -- [ ] AI [ fullNameSpan -- [ ] AI | L (ann :: Anno RdrName) (Qual moduleAlias _) <- listify (const True) decls -- [ ] AI - , moduleAlias == oldAlias -- [ ] AI + , moduleAlias == aliasName importAlias -- [ ] AI , RealSrcSpan fullNameSpan _ <- [locA ann] -- [ ] AI ] -- [ ] AI @@ -232,40 +239,25 @@ disambiguateAliasAtPos :: MonadIO m -- [ ] AI => IdeState -- [ ] AI -> NormalizedFilePath -- [ ] AI - -> [Name] -- ^ names under the cursor, from 'getNamesAtPos' -- [ ] AI + -> Position -- [ ] AI -> [LImportDecl GhcPs] -- [ ] AI - -> ExceptT PluginError m (Maybe (ModuleName, RealSrcSpan)) -- [ ] AI -disambiguateAliasAtPos state nfp namesAtCursor imports = do -- [ ] AI - tcModule <- runActionE "rename.disambiguateAlias" state (useE TypeCheck nfp) -- [ ] AI + -> ExceptT PluginError m (Maybe ImportAlias) -- [ ] AI +disambiguateAliasAtPos state nfp pos imports = do -- [ ] AI + namesAtCursor <- getNamesAtPos state nfp pos -- [ ] AI + tcModule <- runActionE "rename.disambiguateAlias" state (useE TypeCheck nfp) -- [ ] AI let rdrEnv = tcg_rdr_env (tmrTypechecked tcModule) -- [ ] AI pure $ listToMaybe $ do -- [ ] AI - name <- namesAtCursor -- [ ] AI - gre <- maybeToList (lookupGRE_Name rdrEnv name) -- [ ] AI - impSpec <- gre_imp gre -- [ ] AI - let declSpec = is_decl impSpec -- [ ] AI - actualMod = is_mod declSpec -- [ ] AI - aliasName = is_as declSpec -- [ ] AI - L _ decl <- imports -- [ ] AI - guard (unLoc (ideclName decl) == actualMod) -- [ ] AI - Just locatedAlias <- [ideclAs decl] -- [ ] AI - guard (unLoc locatedAlias == aliasName) -- [ ] AI + name <- namesAtCursor -- [ ] AI + gre <- maybeToList (lookupGRE_Name rdrEnv name) -- [ ] AI + impSpec <- gre_imp gre -- [ ] AI + let declSpec = is_decl impSpec -- [ ] AI + specModuleName = moduleName (is_mod declSpec) -- [ ] AI + specAlias = is_as declSpec -- [ ] AI + L _ decl <- imports -- [ ] AI + guard (unLoc (ideclName decl) == specModuleName) -- [ ] AI + Just locatedAlias <- [ideclAs decl] -- [ ] AI RealSrcSpan aliasDeclSpan _ <- [locA locatedAlias] -- [ ] AI - pure (aliasName, aliasDeclSpan) -- [ ] AI - --- | Identify the actual module behind an alias by matching the alias -- [ ] AI --- declaration span against the import list. Used in 'aliasBasedRename' -- [ ] AI --- to find which module owns the alias when multiple imports share it. -- [ ] AI -resolveActualMod -- [ ] AI - :: RealSrcSpan -- ^ span of the alias token in the import declaration -- [ ] AI - -> [LImportDecl GhcPs] -- [ ] AI - -> Maybe ModuleName -- [ ] AI -resolveActualMod aliasSpan imports = listToMaybe -- [ ] AI - [ unLoc (ideclName decl) -- [ ] AI - | L _ decl <- imports -- [ ] AI - , Just locatedAlias <- [ideclAs decl] -- [ ] AI - , RealSrcSpan declSpan _ <- [locA locatedAlias] -- [ ] AI - , declSpan == aliasSpan -- [ ] AI - ] -- [ ] AI + pure (ImportAlias specModuleName specAlias aliasDeclSpan) -- [ ] AI -- | Like 'importAliasUseSiteSpans' but filters to use sites that resolve -- [ ] AI -- to names from @actualMod@, using the typechecked module's 'GlobalRdrEnv'. -- [ ] AI @@ -274,21 +266,21 @@ importAliasUseSiteSpansDisambiguated :: MonadIO m -- [ ] AI => IdeState -- [ ] AI -> NormalizedFilePath -- [ ] AI - -> ModuleName -- ^ alias, e.g. @L@ -- [ ] AI - -> ModuleName -- ^ actual module, e.g. @Control.Lens@ -- [ ] AI + -> ImportAlias -- [ ] AI -> [LHsDecl GhcPs] -- [ ] AI -> ExceptT PluginError m [RealSrcSpan] -- [ ] AI -importAliasUseSiteSpansDisambiguated state nfp oldAlias actualMod decls = do -- [ ] AI +importAliasUseSiteSpansDisambiguated state nfp importAlias decls = do -- [ ] AI tcModule <- runActionE "rename.useSiteSpans" state (useE TypeCheck nfp) -- [ ] AI - let rdrEnv = tcg_rdr_env (tmrTypechecked tcModule) -- [ ] AI - allSpans = importAliasUseSiteSpansWithOcc oldAlias decls -- [ ] AI + let rdrEnv = tcg_rdr_env (tmrTypechecked tcModule) -- [ ] AI + allSpans = importAliasUseSiteSpansWithOcc (aliasName importAlias) decls -- [ ] AI + ImportAlias actualMod _ _ = importAlias -- [ ] AI pure -- [ ] AI [ rsp -- [ ] AI | (occName, rsp) <- allSpans -- [ ] AI - , gre <- maybeToList -- [ ] AI - (lookupGRE_RdrName (Qual oldAlias occName) rdrEnv) -- [ ] AI - , impSpec <- gre_imp gre -- [ ] AI - , is_mod (is_decl impSpec) == actualMod -- [ ] AI + , gre <- lookupGRE rdrEnv $ -- [ ] AI + LookupRdrName (Qual (aliasName importAlias) occName) AllRelevantGREs -- [ ] AI + , impSpec <- gre_imp gre -- [ ] AI + , moduleName (is_mod (is_decl impSpec)) == actualMod -- [ ] AI ] -- [ ] AI -- | Like 'importAliasUseSiteSpans' but also returns the 'OccName' of each -- [ ] AI From cc3f072eed84e5cb5d85730b81b5da2cca4d1648 Mon Sep 17 00:00:00 2001 From: izuzu Date: Thu, 19 Mar 2026 16:41:56 +0800 Subject: [PATCH 16/31] Avoid cyclic dependency involving `getNamesAtPos` --- .../src/Ide/Plugin/Rename/ImportAlias.hs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs index 877aa96a9e..47101260c0 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs @@ -134,21 +134,26 @@ findImportAliasUseAtPos pos decls imports = -- on a qualifier at a use site. If multiple imports share the same alias, falls -- back to the typechecked module's 'GlobalRdrEnv' to disambiguate. -- Returns @Nothing@ if the cursor is not on any alias declaration or qualifier. +-- HACK: The first argument is `Rename.getNamesAtPos`, parameterized to avoid a +-- circular dependency. resolveAliasAtPos -- [ ] AI :: MonadIO m -- [ ] AI - => IdeState -- [ ] AI + => (IdeState -> NormalizedFilePath -> Position -> ExceptT PluginError m [Name]) -- [ ] AI + -> IdeState -- [ ] AI -> NormalizedFilePath -- [ ] AI -> Position -- [ ] AI -> [LHsDecl GhcPs] -- [ ] AI -> [LImportDecl GhcPs] -- [ ] AI -> ExceptT PluginError m (Maybe ImportAlias) -- [ ] AI -resolveAliasAtPos state nfp pos decls imports = -- [ ] AI +resolveAliasAtPos getNamesAtPosFn state nfp pos decls imports = -- [ ] AI case findImportAliasDeclAtPos pos imports of -- [ ] AI Just result -> pure (Just result) -- [ ] AI Nothing -> case findImportAliasUseAtPos pos decls imports of -- [ ] AI [] -> pure Nothing -- [ ] AI [result] -> pure (Just result) -- [ ] AI - _many -> disambiguateAliasAtPos state nfp pos imports -- [ ] AI + _many -> do + namesAtPos <- getNamesAtPosFn state nfp pos + disambiguateAliasAtPos state nfp namesAtPos imports -- [ ] AI -- | Build a 'WorkspaceEdit' renaming an import alias and all its use sites. aliasBasedRename -- [ ] AI @@ -239,15 +244,14 @@ disambiguateAliasAtPos :: MonadIO m -- [ ] AI => IdeState -- [ ] AI -> NormalizedFilePath -- [ ] AI - -> Position -- [ ] AI + -> [Name] -- [ ] AI -> [LImportDecl GhcPs] -- [ ] AI -> ExceptT PluginError m (Maybe ImportAlias) -- [ ] AI -disambiguateAliasAtPos state nfp pos imports = do -- [ ] AI - namesAtCursor <- getNamesAtPos state nfp pos -- [ ] AI +disambiguateAliasAtPos state nfp namesAtPos imports = do -- [ ] AI tcModule <- runActionE "rename.disambiguateAlias" state (useE TypeCheck nfp) -- [ ] AI let rdrEnv = tcg_rdr_env (tmrTypechecked tcModule) -- [ ] AI pure $ listToMaybe $ do -- [ ] AI - name <- namesAtCursor -- [ ] AI + name <- namesAtPos -- [ ] AI gre <- maybeToList (lookupGRE_Name rdrEnv name) -- [ ] AI impSpec <- gre_imp gre -- [ ] AI let declSpec = is_decl impSpec -- [ ] AI From ea70948f898d06d36a2999d94d4f918119f412c2 Mon Sep 17 00:00:00 2001 From: izuzu Date: Thu, 19 Mar 2026 17:13:38 +0800 Subject: [PATCH 17/31] Take responsibility for all AI-assisted code --- .../src/Ide/Plugin/Rename/ImportAlias.hs | 402 +++++++++--------- 1 file changed, 201 insertions(+), 201 deletions(-) diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs index 47101260c0..0c77898dc5 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs @@ -24,18 +24,18 @@ The basic approach is this: The common case, with each alias corresponding to one module, should be very fast, even if the user renames multiple aliases in quick succession. -} -module Ide.Plugin.Rename.ImportAlias -- [ ] AI - ( getParsedModuleStale -- [ ] AI - , ImportAlias (..) -- [ ] AI - , findImportAliasDeclAtPos -- [ ] AI - , findImportAliasUseAtPos -- [ ] AI - , resolveAliasAtPos -- [ ] AI - , aliasBasedRename -- [ ] AI - , importAliasUseSiteSpans -- [ ] AI - , importAliasUseSiteEdit -- [ ] AI - , importAliasDeclEdit -- [ ] AI - , rangeContainsPosition -- [ ] AI - ) where -- [ ] AI +module Ide.Plugin.Rename.ImportAlias + ( getParsedModuleStale + , ImportAlias (..) + , findImportAliasDeclAtPos + , findImportAliasUseAtPos + , resolveAliasAtPos + , aliasBasedRename + , importAliasUseSiteSpans + , importAliasUseSiteEdit + , importAliasDeclEdit + , rangeContainsPosition + ) where import Control.Lens ((^.)) import Control.Monad (guard) @@ -69,65 +69,65 @@ data ImportAlias = ImportAlias -- | Fetch the parsed module for a file, accepting a stale result. -- Returns @Nothing@ if the file has never been indexed. -getParsedModuleStale -- [ ] AI - :: MonadIO m -- [ ] AI - => IdeState -- [ ] AI - -> NormalizedFilePath -- [ ] AI - -> m (Maybe ParsedModule) -- [ ] AI -getParsedModuleStale state nfp = -- [ ] AI - liftIO $ fmap fst <$> -- [ ] AI - runAction "rename.getParsedModuleStale" state -- [ ] AI - (useWithStale GetParsedModule nfp) -- [ ] AI +getParsedModuleStale + :: MonadIO m + => IdeState + -> NormalizedFilePath + -> m (Maybe ParsedModule) +getParsedModuleStale state nfp = + liftIO $ fmap fst <$> + runAction "rename.getParsedModuleStale" state + (useWithStale GetParsedModule nfp) -- | Find the 'ImportAlias' for the alias declaration at the cursor, such as -- @Alias@ in @import Module as Alias@. -findImportAliasDeclAtPos -- [ ] AI - :: Position -- [ ] AI - -> [LImportDecl GhcPs] -- [ ] AI - -> Maybe ImportAlias -- [ ] AI -findImportAliasDeclAtPos pos imports = listToMaybe -- [ ] AI - [ ImportAlias {aliasModuleName, aliasName, aliasDeclSpan} -- [ ] AI - | _locatedImport@(L _ decl) <- imports -- [ ] AI - , Just locatedAlias <- [ideclAs decl] -- [ ] AI - , let aliasName = unLoc locatedAlias -- [ ] AI - , RealSrcSpan aliasDeclSpan _ <- [locA locatedAlias] -- [ ] AI - , rangeContainsPosition (realSrcSpanToRange aliasDeclSpan) pos -- [ ] AI - , let aliasModuleName = unLoc (ideclName decl) -- [ ] AI - ] -- [ ] AI +findImportAliasDeclAtPos + :: Position + -> [LImportDecl GhcPs] + -> Maybe ImportAlias +findImportAliasDeclAtPos pos imports = listToMaybe + [ ImportAlias {aliasModuleName, aliasName, aliasDeclSpan} + | _locatedImport@(L _ decl) <- imports + , Just locatedAlias <- [ideclAs decl] + , let aliasName = unLoc locatedAlias + , RealSrcSpan aliasDeclSpan _ <- [locA locatedAlias] + , rangeContainsPosition (realSrcSpanToRange aliasDeclSpan) pos + , let aliasModuleName = unLoc (ideclName decl) + ] -- | Find the 'ImportAlias' matching the name qualifier at the cursor, such as -- @Alias@ in @Alias.name@. -- Returns multiple values if multiple modules share the same alias. -findImportAliasUseAtPos -- [ ] AI - :: Position -- [ ] AI - -> [LHsDecl GhcPs] -- [ ] AI - -> [LImportDecl GhcPs] -- [ ] AI - -> [ImportAlias] -- [ ] AI -findImportAliasUseAtPos pos decls imports = -- [ ] AI - case listToMaybe -- [ ] AI - [ qualifier -- [ ] AI - | L (ann :: Anno RdrName) (Qual qualifier _) <- listify (const True) decls -- [ ] AI - , RealSrcSpan useSiteSpan _ <- [locA ann] -- [ ] AI - , rangeContainsPosition (realSrcSpanToRange useSiteSpan) pos -- [ ] AI - , let qualifierLength = fromIntegral (length (moduleNameString qualifier)) -- [ ] AI - start = realSrcSpanStart useSiteSpan -- [ ] AI - line = fromIntegral (srcLocLine start) -- [ ] AI - startColumn = fromIntegral (srcLocCol start) -- [ ] AI - qualifierRange = Range -- [ ] AI - (Position (line - 1) (startColumn - 1)) -- [ ] AI - (Position (line - 1) (startColumn - 1 + qualifierLength)) -- [ ] AI - , rangeContainsPosition qualifierRange pos -- [ ] AI - ] of -- [ ] AI - Nothing -> [] -- [ ] AI - Just qualifierAtPos -> -- [ ] AI - [ ImportAlias {aliasModuleName, aliasName, aliasDeclSpan} -- [ ] AI - | _locatedImport@(L _ decl) <- imports -- [ ] AI - , Just locatedAlias <- [ideclAs decl] -- [ ] AI - , let aliasName = unLoc locatedAlias -- [ ] AI - , aliasName == qualifierAtPos -- [ ] AI - , RealSrcSpan aliasDeclSpan _ <- [locA locatedAlias] -- [ ] AI +findImportAliasUseAtPos + :: Position + -> [LHsDecl GhcPs] + -> [LImportDecl GhcPs] + -> [ImportAlias] +findImportAliasUseAtPos pos decls imports = + case listToMaybe + [ qualifier + | L (ann :: Anno RdrName) (Qual qualifier _) <- listify (const True) decls + , RealSrcSpan useSiteSpan _ <- [locA ann] + , rangeContainsPosition (realSrcSpanToRange useSiteSpan) pos + , let qualifierLength = fromIntegral (length (moduleNameString qualifier)) + start = realSrcSpanStart useSiteSpan + line = fromIntegral (srcLocLine start) + startColumn = fromIntegral (srcLocCol start) + qualifierRange = Range + (Position (line - 1) (startColumn - 1)) + (Position (line - 1) (startColumn - 1 + qualifierLength)) + , rangeContainsPosition qualifierRange pos + ] of + Nothing -> [] + Just qualifierAtPos -> + [ ImportAlias {aliasModuleName, aliasName, aliasDeclSpan} + | _locatedImport@(L _ decl) <- imports + , Just locatedAlias <- [ideclAs decl] + , let aliasName = unLoc locatedAlias + , aliasName == qualifierAtPos + , RealSrcSpan aliasDeclSpan _ <- [locA locatedAlias] , let aliasModuleName = unLoc (ideclName decl) - ] -- [ ] AI + ] -- | Return the module name and declaration span for the alias being renamed at -- the cursor. The cursor may be on the alias token in an import declaration or @@ -136,166 +136,166 @@ findImportAliasUseAtPos pos decls imports = -- Returns @Nothing@ if the cursor is not on any alias declaration or qualifier. -- HACK: The first argument is `Rename.getNamesAtPos`, parameterized to avoid a -- circular dependency. -resolveAliasAtPos -- [ ] AI - :: MonadIO m -- [ ] AI - => (IdeState -> NormalizedFilePath -> Position -> ExceptT PluginError m [Name]) -- [ ] AI - -> IdeState -- [ ] AI - -> NormalizedFilePath -- [ ] AI - -> Position -- [ ] AI - -> [LHsDecl GhcPs] -- [ ] AI - -> [LImportDecl GhcPs] -- [ ] AI - -> ExceptT PluginError m (Maybe ImportAlias) -- [ ] AI -resolveAliasAtPos getNamesAtPosFn state nfp pos decls imports = -- [ ] AI - case findImportAliasDeclAtPos pos imports of -- [ ] AI - Just result -> pure (Just result) -- [ ] AI - Nothing -> case findImportAliasUseAtPos pos decls imports of -- [ ] AI - [] -> pure Nothing -- [ ] AI - [result] -> pure (Just result) -- [ ] AI +resolveAliasAtPos + :: MonadIO m + => (IdeState -> NormalizedFilePath -> Position -> ExceptT PluginError m [Name]) + -> IdeState + -> NormalizedFilePath + -> Position + -> [LHsDecl GhcPs] + -> [LImportDecl GhcPs] + -> ExceptT PluginError m (Maybe ImportAlias) +resolveAliasAtPos getNamesAtPosFn state nfp pos decls imports = + case findImportAliasDeclAtPos pos imports of + Just result -> pure (Just result) + Nothing -> case findImportAliasUseAtPos pos decls imports of + [] -> pure Nothing + [result] -> pure (Just result) _many -> do namesAtPos <- getNamesAtPosFn state nfp pos - disambiguateAliasAtPos state nfp namesAtPos imports -- [ ] AI + disambiguateAliasAtPos state nfp namesAtPos imports -- | Build a 'WorkspaceEdit' renaming an import alias and all its use sites. -aliasBasedRename -- [ ] AI - :: MonadIO m -- [ ] AI - => IdeState -- [ ] AI - -> NormalizedFilePath -- [ ] AI - -> Uri -- [ ] AI - -> ImportAlias -- [ ] AI - -> [LImportDecl GhcPs] -- [ ] AI - -> [LHsDecl GhcPs] -- [ ] AI - -> T.Text -- [ ] AI - -> ExceptT PluginError m (MessageResult Method_TextDocumentRename) -- [ ] AI -aliasBasedRename state nfp uri importAlias imports decls newNameText = do -- [ ] AI - let oldAlias = aliasName importAlias -- [ ] AI - declSpan = aliasDeclSpan importAlias -- [ ] AI - duplicateAlias = -- [ ] AI - length [ () -- [ ] AI - | L _ decl <- imports -- [ ] AI - , Just locAlias <- [ideclAs decl] -- [ ] AI - , unLoc locAlias == oldAlias -- [ ] AI - ] > 1 -- [ ] AI - useSiteSpans <- -- [ ] AI - if duplicateAlias -- [ ] AI - then importAliasUseSiteSpansDisambiguated state nfp importAlias decls -- [ ] AI - else pure $ importAliasUseSiteSpans importAlias decls -- [ ] AI - let declEdit = importAliasDeclEdit newNameText declSpan -- [ ] AI - useEdits = map (importAliasUseSiteEdit oldAlias newNameText) useSiteSpans -- [ ] AI - allEdits = declEdit : useEdits -- [ ] AI - verTxtDocId <- liftIO $ runAction "rename.getVersionedTextDoc" state $ -- [ ] AI - getVersionedTextDoc (TextDocumentIdentifier uri) -- [ ] AI - let fileChanges = Just $ M.singleton (verTxtDocId ^. L.uri) allEdits -- [ ] AI +aliasBasedRename + :: MonadIO m + => IdeState + -> NormalizedFilePath + -> Uri + -> ImportAlias + -> [LImportDecl GhcPs] + -> [LHsDecl GhcPs] + -> T.Text + -> ExceptT PluginError m (MessageResult Method_TextDocumentRename) +aliasBasedRename state nfp uri importAlias imports decls newNameText = do + let oldAlias = aliasName importAlias + declSpan = aliasDeclSpan importAlias + duplicateAlias = + length [ () + | L _ decl <- imports + , Just locAlias <- [ideclAs decl] + , unLoc locAlias == oldAlias + ] > 1 + useSiteSpans <- + if duplicateAlias + then importAliasUseSiteSpansDisambiguated state nfp importAlias decls + else pure $ importAliasUseSiteSpans importAlias decls + let declEdit = importAliasDeclEdit newNameText declSpan + useEdits = map (importAliasUseSiteEdit oldAlias newNameText) useSiteSpans + allEdits = declEdit : useEdits + verTxtDocId <- liftIO $ runAction "rename.getVersionedTextDoc" state $ + getVersionedTextDoc (TextDocumentIdentifier uri) + let fileChanges = Just $ M.singleton (verTxtDocId ^. L.uri) allEdits -- TODO: Replace 'Nothing' with meaningful details for the workspace edit. - workspaceEdit = WorkspaceEdit fileChanges Nothing Nothing -- [ ] AI - pure $ InL workspaceEdit -- [ ] AI + workspaceEdit = WorkspaceEdit fileChanges Nothing Nothing + pure $ InL workspaceEdit -- | Collect the 'RealSrcSpan' of every qualified use of @oldAlias@, such as in -- @oldAlias.foo@, @oldAlias.bar@, and so on. -- Does not disambiguate if multiple imports share the alias. -importAliasUseSiteSpans -- [ ] AI - :: ImportAlias -- [ ] AI - -> [LHsDecl GhcPs] -- [ ] AI - -> [RealSrcSpan] -- [ ] AI -importAliasUseSiteSpans importAlias decls = -- [ ] AI - [ fullNameSpan -- [ ] AI - | L (ann :: Anno RdrName) (Qual moduleAlias _) <- listify (const True) decls -- [ ] AI - , moduleAlias == aliasName importAlias -- [ ] AI - , RealSrcSpan fullNameSpan _ <- [locA ann] -- [ ] AI - ] -- [ ] AI +importAliasUseSiteSpans + :: ImportAlias + -> [LHsDecl GhcPs] + -> [RealSrcSpan] +importAliasUseSiteSpans importAlias decls = + [ fullNameSpan + | L (ann :: Anno RdrName) (Qual moduleAlias _) <- listify (const True) decls + , moduleAlias == aliasName importAlias + , RealSrcSpan fullNameSpan _ <- [locA ann] + ] -- | Build a 'TextEdit' replacing the qualifier part in a qualified name (like -- from @Alias.name@ to @NewAlias.name@). -- (NOTE: GHC uses 1-based positioning; LSP uses 0-based.) -importAliasUseSiteEdit -- [ ] AI - :: ModuleName -- ^ old alias, used to compute the qualifier width -- [ ] AI - -> T.Text -- ^ new alias text -- [ ] AI - -> RealSrcSpan -- ^ span of the full qualified name, such as @Alias.name@ -- [ ] AI - -> TextEdit -- [ ] AI -importAliasUseSiteEdit oldAlias newAlias fullNameSpan = TextEdit range newAlias -- [ ] AI - where -- [ ] AI - start = realSrcSpanStart fullNameSpan -- [ ] AI - line = fromIntegral (srcLocLine start) - 1 -- [ ] AI - startCol = fromIntegral (srcLocCol start) - 1 -- [ ] AI - endCol = startCol + fromIntegral (length (moduleNameString oldAlias)) -- [ ] AI - range = Range (Position line startCol) (Position line endCol) -- [ ] AI +importAliasUseSiteEdit + :: ModuleName -- ^ old alias, used to compute the qualifier width + -> T.Text -- ^ new alias text + -> RealSrcSpan -- ^ span of the full qualified name, such as @Alias.name@ + -> TextEdit +importAliasUseSiteEdit oldAlias newAlias fullNameSpan = TextEdit range newAlias + where + start = realSrcSpanStart fullNameSpan + line = fromIntegral (srcLocLine start) - 1 + startCol = fromIntegral (srcLocCol start) - 1 + endCol = startCol + fromIntegral (length (moduleNameString oldAlias)) + range = Range (Position line startCol) (Position line endCol) -- | Build a 'TextEdit' replacing the alias token in an import declaration (like -- from @import Module as Alias@ to @import Module as NewAlias@). -importAliasDeclEdit -- [ ] AI - :: T.Text -- ^ new alias text -- [ ] AI - -> RealSrcSpan -- ^ span of @Alias@ in @import Module as Alias@ -- [ ] AI - -> TextEdit -- [ ] AI -importAliasDeclEdit newAlias rsp = TextEdit (realSrcSpanToRange rsp) newAlias -- [ ] AI +importAliasDeclEdit + :: T.Text -- ^ new alias text + -> RealSrcSpan -- ^ span of @Alias@ in @import Module as Alias@ + -> TextEdit +importAliasDeclEdit newAlias rsp = TextEdit (realSrcSpanToRange rsp) newAlias -- | Check whether a range contains a position (inclusive start, exclusive end). -rangeContainsPosition :: Range -> Position -> Bool -- [ ] AI -rangeContainsPosition (Range (Position sl sc) (Position el ec)) (Position l c) -- [ ] AI - = (l > sl || (l == sl && c >= sc)) -- [ ] AI - && (l < el || (l == el && c < ec)) -- [ ] AI +rangeContainsPosition :: Range -> Position -> Bool +rangeContainsPosition (Range (Position sl sc) (Position el ec)) (Position l c) + = (l > sl || (l == sl && c >= sc)) + && (l < el || (l == el && c < ec)) --------------------------------------------------------------------------------------------------- -- Internal helpers --- | Resolve an ambiguous alias use site by consulting the typechecked -- [ ] AI --- module's 'GlobalRdrEnv'. Used when multiple imports share the same alias. -- [ ] AI --- The caller is responsible for providing the names under the cursor. -- [ ] AI +-- | Resolve an ambiguous alias use site by consulting the typechecked +-- module's 'GlobalRdrEnv'. Used when multiple imports share the same alias. +-- The caller is responsible for providing the names under the cursor. -- TODO: Rename it to @disambiguateAliasUseAtPos@. -disambiguateAliasAtPos -- [ ] AI - :: MonadIO m -- [ ] AI - => IdeState -- [ ] AI - -> NormalizedFilePath -- [ ] AI - -> [Name] -- [ ] AI - -> [LImportDecl GhcPs] -- [ ] AI - -> ExceptT PluginError m (Maybe ImportAlias) -- [ ] AI -disambiguateAliasAtPos state nfp namesAtPos imports = do -- [ ] AI - tcModule <- runActionE "rename.disambiguateAlias" state (useE TypeCheck nfp) -- [ ] AI - let rdrEnv = tcg_rdr_env (tmrTypechecked tcModule) -- [ ] AI - pure $ listToMaybe $ do -- [ ] AI - name <- namesAtPos -- [ ] AI - gre <- maybeToList (lookupGRE_Name rdrEnv name) -- [ ] AI - impSpec <- gre_imp gre -- [ ] AI - let declSpec = is_decl impSpec -- [ ] AI - specModuleName = moduleName (is_mod declSpec) -- [ ] AI - specAlias = is_as declSpec -- [ ] AI - L _ decl <- imports -- [ ] AI - guard (unLoc (ideclName decl) == specModuleName) -- [ ] AI - Just locatedAlias <- [ideclAs decl] -- [ ] AI - RealSrcSpan aliasDeclSpan _ <- [locA locatedAlias] -- [ ] AI - pure (ImportAlias specModuleName specAlias aliasDeclSpan) -- [ ] AI +disambiguateAliasAtPos + :: MonadIO m + => IdeState + -> NormalizedFilePath + -> [Name] + -> [LImportDecl GhcPs] + -> ExceptT PluginError m (Maybe ImportAlias) +disambiguateAliasAtPos state nfp namesAtPos imports = do + tcModule <- runActionE "rename.disambiguateAlias" state (useE TypeCheck nfp) + let rdrEnv = tcg_rdr_env (tmrTypechecked tcModule) + pure $ listToMaybe $ do + name <- namesAtPos + gre <- maybeToList (lookupGRE_Name rdrEnv name) + impSpec <- gre_imp gre + let declSpec = is_decl impSpec + specModuleName = moduleName (is_mod declSpec) + specAlias = is_as declSpec + L _ decl <- imports + guard (unLoc (ideclName decl) == specModuleName) + Just locatedAlias <- [ideclAs decl] + RealSrcSpan aliasDeclSpan _ <- [locA locatedAlias] + pure (ImportAlias specModuleName specAlias aliasDeclSpan) --- | Like 'importAliasUseSiteSpans' but filters to use sites that resolve -- [ ] AI --- to names from @actualMod@, using the typechecked module's 'GlobalRdrEnv'. -- [ ] AI --- Used when multiple imports share the same alias. -- [ ] AI -importAliasUseSiteSpansDisambiguated -- [ ] AI - :: MonadIO m -- [ ] AI - => IdeState -- [ ] AI - -> NormalizedFilePath -- [ ] AI - -> ImportAlias -- [ ] AI - -> [LHsDecl GhcPs] -- [ ] AI - -> ExceptT PluginError m [RealSrcSpan] -- [ ] AI -importAliasUseSiteSpansDisambiguated state nfp importAlias decls = do -- [ ] AI - tcModule <- runActionE "rename.useSiteSpans" state (useE TypeCheck nfp) -- [ ] AI - let rdrEnv = tcg_rdr_env (tmrTypechecked tcModule) -- [ ] AI - allSpans = importAliasUseSiteSpansWithOcc (aliasName importAlias) decls -- [ ] AI - ImportAlias actualMod _ _ = importAlias -- [ ] AI - pure -- [ ] AI - [ rsp -- [ ] AI - | (occName, rsp) <- allSpans -- [ ] AI - , gre <- lookupGRE rdrEnv $ -- [ ] AI - LookupRdrName (Qual (aliasName importAlias) occName) AllRelevantGREs -- [ ] AI - , impSpec <- gre_imp gre -- [ ] AI - , moduleName (is_mod (is_decl impSpec)) == actualMod -- [ ] AI - ] -- [ ] AI +-- | Like 'importAliasUseSiteSpans' but filters to use sites that resolve +-- to names from @actualMod@, using the typechecked module's 'GlobalRdrEnv'. +-- Used when multiple imports share the same alias. +importAliasUseSiteSpansDisambiguated + :: MonadIO m + => IdeState + -> NormalizedFilePath + -> ImportAlias + -> [LHsDecl GhcPs] + -> ExceptT PluginError m [RealSrcSpan] +importAliasUseSiteSpansDisambiguated state nfp importAlias decls = do + tcModule <- runActionE "rename.useSiteSpans" state (useE TypeCheck nfp) + let rdrEnv = tcg_rdr_env (tmrTypechecked tcModule) + allSpans = importAliasUseSiteSpansWithOcc (aliasName importAlias) decls + ImportAlias actualMod _ _ = importAlias + pure + [ rsp + | (occName, rsp) <- allSpans + , gre <- lookupGRE rdrEnv $ + LookupRdrName (Qual (aliasName importAlias) occName) AllRelevantGREs + , impSpec <- gre_imp gre + , moduleName (is_mod (is_decl impSpec)) == actualMod + ] --- | Like 'importAliasUseSiteSpans' but also returns the 'OccName' of each -- [ ] AI --- use, needed for 'GlobalRdrEnv' lookup in the disambiguated path. -- [ ] AI -importAliasUseSiteSpansWithOcc -- [ ] AI - :: ModuleName -- [ ] AI - -> [LHsDecl GhcPs] -- [ ] AI - -> [(OccName, RealSrcSpan)] -- [ ] AI -importAliasUseSiteSpansWithOcc oldAlias decls = -- [ ] AI - [ (occName, rsp) -- [ ] AI - | L (ann :: Anno RdrName) (Qual moduleAlias occName) <- listify (const True) decls -- [ ] AI - , moduleAlias == oldAlias -- [ ] AI - , RealSrcSpan rsp _ <- [locA ann] -- [ ] AI - ] -- [ ] AI +-- | Like 'importAliasUseSiteSpans' but also returns the 'OccName' of each +-- use, needed for 'GlobalRdrEnv' lookup in the disambiguated path. +importAliasUseSiteSpansWithOcc + :: ModuleName + -> [LHsDecl GhcPs] + -> [(OccName, RealSrcSpan)] +importAliasUseSiteSpansWithOcc oldAlias decls = + [ (occName, rsp) + | L (ann :: Anno RdrName) (Qual moduleAlias occName) <- listify (const True) decls + , moduleAlias == oldAlias + , RealSrcSpan rsp _ <- [locA ann] + ] From 0138f360fb03e62b990c87c3c5e4c7de28df95aa Mon Sep 17 00:00:00 2001 From: izuzu Date: Thu, 19 Mar 2026 17:32:19 +0800 Subject: [PATCH 18/31] Move alias-renaming logic out of `Rename.hs` --- .../src/Ide/Plugin/Rename.hs | 304 +++--------------- .../src/Ide/Plugin/Rename/ImportAlias.hs | 2 +- 2 files changed, 38 insertions(+), 268 deletions(-) diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs index ee8dcf0562..5d0e14c865 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs @@ -9,7 +9,6 @@ module Ide.Plugin.Rename (descriptor, Log) where -import Control.Applicative (Alternative ((<|>))) import Control.Lens ((^.)) import Control.Monad import Control.Monad.Except (ExceptT, throwError) @@ -54,6 +53,7 @@ import Ide.Logger (Pretty (..), cmapWithPrio) import Ide.Plugin.Error import Ide.Plugin.Properties +import qualified Ide.Plugin.Rename.ImportAlias as ImportAlias import qualified Ide.Plugin.Rename.ModuleName as ModuleName import Ide.PluginUtils import Ide.Types @@ -91,31 +91,18 @@ descriptor recorder pluginId = mkExactprintPluginDescriptor exactPrintRecorder $ prepareRenameProvider :: PluginMethodHandler IdeState Method_TextDocumentPrepareRename prepareRenameProvider state _pluginId (PrepareRenameParams (TextDocumentIdentifier uri) pos _progressToken) = do nfp <- getNormalizedFilePathE uri - -- Step 5 (design decision 1): try alias rename first; fall through to -- [x] AI - -- name-based rename if the cursor is not on an alias token. -- [x] AI - maybeParsed <- getParsedModuleStale state nfp -- [x] AI - case maybeParsed of -- [x] AI - Nothing -> throwError $ PluginInternalError -- [x] AI - "Cannot rename: HLS has not yet parsed this module. Please wait for indexing to complete and try again." -- [x] AI - Just parsed -> do -- [x] AI - let hsModule = unLoc $ pm_parsed_source parsed -- [x] AI - imports = hsmodImports hsModule -- [x] AI - decls = hsmodDecls hsModule -- [x] AI - case (findImportAliasDeclAtPos pos imports -- [x] AI - <|> findImportAliasUseAtPos pos decls imports) of -- [x] AI - Just _ -> -- [x] AI - -- Step 5 (design decision 2): return defaultBehavior. -- [x] AI - -- TODO: return an explicit range and placeholder text -- [x] AI - -- (the InL and InR (InL ...) variants of -- [x] AI - -- PrepareRenameResult) so the client highlights exactly -- [x] AI - -- the alias token and pre-fills the current alias text -- [x] AI - -- in the rename box, rather than relying on -- [x] AI - -- defaultBehavior. -- [x] AI - pure $ InL $ PrepareRenameResult $ InR $ InR $ -- [x] AI - PrepareRenameDefaultBehavior True -- [x] AI - Nothing -> do -- [x] AI - -- Fall through to name-based rename. -- [x] AI - -- + maybeParsed <- ImportAlias.getParsedModuleStale state nfp + case maybeParsed of + Nothing -> throwError $ PluginInternalError + "Cannot rename: HLS has not yet parsed this module. Please wait for indexing to complete and try again." + Just parsed -> do + let hsModule = unLoc $ pm_parsed_source parsed + imports = hsmodImports hsModule + decls = hsmodDecls hsModule + maybeAlias <- ImportAlias.resolveAliasAtPos getNamesAtPos state nfp pos decls imports + case maybeAlias of + Just _ -> pure $ InL $ PrepareRenameResult $ InR $ InR $ PrepareRenameDefaultBehavior True -- [ ] AI + Nothing -> do -- 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" @@ -125,63 +112,35 @@ prepareRenameProvider state _pluginId (PrepareRenameParams (TextDocumentIdentifi -- so that the full rename handler can give more informative error about them. namesUnderCursor <- getNamesAtPos state nfp pos let renameValid = not $ null namesUnderCursor - pure $ InL $ PrepareRenameResult $ InR $ InR $ - PrepareRenameDefaultBehavior renameValid + 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 - -- Step 5 (design decision 1): try alias rename first; fall through to -- [x] AI - -- name-based rename if the cursor is not on an alias token. -- [x] AI - maybeParsed <- getParsedModuleStale state nfp -- [x] AI - case maybeParsed of -- [x] AI - Nothing -> throwError $ PluginInternalError -- [x] AI + maybeParsed <- ImportAlias.getParsedModuleStale state nfp + case maybeParsed of + Nothing -> throwError $ PluginInternalError "Cannot rename: HLS has not yet parsed this module. Please wait for indexing to complete and try again." -- [x] AI - Just parsed -> do -- [x] AI - let hsModule = unLoc $ pm_parsed_source parsed -- [x] AI - imports = hsmodImports hsModule -- [x] AI - decls = hsmodDecls hsModule -- [x] AI - case (findImportAliasDeclAtPos pos imports -- [ ] AI - <|> findImportAliasUseAtPos pos decls imports) of -- [ ] AI - Just (oldAlias, aliasSpan) -> -- [x] AI - aliasBasedRename state uri oldAlias aliasSpan decls newNameText -- [x] AI - Nothing -> -- [x] AI - -- Fall through to name-based rename. -- [x] AI - nameBasedRename state pluginId nfp pos newNameText -- [x] AI - --- | Alias-based rename: build 'TextEdit's for the import alias declaration -- [x] AI --- and all use sites, then wrap in a 'WorkspaceEdit'. -- [x] AI -aliasBasedRename :: -- [x] AI - MonadIO m => -- [x] AI - IdeState -> -- [x] AI - Uri -> -- [x] AI - ModuleName -> -- [x] AI - RealSrcSpan -> -- [x] AI - [LHsDecl GhcPs] -> -- [x] AI - T.Text -> -- [x] AI - ExceptT PluginError m (MessageResult Method_TextDocumentRename) -- [x] AI -aliasBasedRename state uri oldAlias aliasSpan decls newNameText = do -- [x] AI - let useSiteSpans = importAliasUseSiteSpans oldAlias decls -- [x] AI - declEdit = importAliasDeclEdit newNameText aliasSpan -- [x] AI - useEdits = map (importAliasUseSiteEdit oldAlias newNameText) useSiteSpans -- [x] AI - allEdits = declEdit : useEdits -- [x] AI - verTxtDocId <- liftIO $ runAction "rename: getVersionedTextDoc" state $ -- [x] AI - getVersionedTextDoc (TextDocumentIdentifier uri) -- [x] AI - let fileChanges = Just $ M.singleton (verTxtDocId ^. L.uri) allEdits -- [x] AI - -- TODO: Replace 'Nothing' with meaningful details for the workspace edit. - workspaceEdit = WorkspaceEdit fileChanges Nothing Nothing -- [x] AI - pure $ InL workspaceEdit -- [x] AI - --- | Name-based rename: the original renameProvider logic, extracted so the -- [x] AI --- alias branch can fall through to it cleanly. -- [x] AI -nameBasedRename :: -- [x] AI - IdeState -> -- [x] AI - PluginId -> -- [x] AI - NormalizedFilePath -> -- [x] AI - Position -> -- [x] AI - T.Text -> -- [x] AI - ExceptT PluginError (HandlerM config) (MessageResult Method_TextDocumentRename) -- [x] AI -nameBasedRename state pluginId nfp pos newNameText = do -- [x] AI + Just parsed -> do + let hsModule = unLoc $ pm_parsed_source parsed + imports = hsmodImports hsModule + decls = hsmodDecls hsModule + maybeAlias <- ImportAlias.resolveAliasAtPos getNamesAtPos state nfp pos decls imports + case maybeAlias of + Just importAlias -> + ImportAlias.aliasBasedRename state nfp uri importAlias imports decls newNameText + Nothing -> + nameBasedRename state pluginId nfp pos newNameText + +-- | Name-based rename: the original rename logic. +nameBasedRename :: + IdeState -> + PluginId -> + NormalizedFilePath -> + Position -> + T.Text -> + ExceptT PluginError (HandlerM config) (MessageResult Method_TextDocumentRename) +nameBasedRename state pluginId nfp pos newNameText = do directOldNames <- getNamesAtPos state nfp pos directRefs <- concat <$> mapM (refsAtName state nfp) directOldNames @@ -235,195 +194,6 @@ failWhenImportOrExport state nfp refLocs names = do (Just _, Nothing) -> throwError $ PluginInternalError "Explicit export list required for renaming" _ -> pure () ---------------------------------------------------------------------------------------------------- --- Qualified alias renaming -- [x] AI --- -- [x] AI --- Step 1: fetch the parsed AST via GetParsedModule. -- [x] AI --- -- [x] AI --- Import aliases (e.g. `import Data.List as L`) survive only in the parsed (`GhcPs`) AST. -- [x] AI --- They are erased during resolving (called the "renaming pass" within GHC), so the HIE AST cannot be used to locate or replace them. -- [x] AI --- The helper below fetches the parsed module using `useWithStale` so it never blocks -- [x] AI --- the UI while GHC is still loading. -- [x] AI - --- | Fetch the parsed module for a file, accepting a possibly stale result. -- [x] AI --- Returns @Nothing@ if the file has not yet been indexed at all. -- [x] AI -getParsedModuleStale :: -- [x] AI - MonadIO m => -- [x] AI - IdeState -> -- [x] AI - NormalizedFilePath -> -- [x] AI - m (Maybe ParsedModule) -- [x] AI -getParsedModuleStale state nfp = -- [x] AI - liftIO $ fmap fst <$> -- [x] AI - runAction "rename.getParsedModuleStale" state -- [x] AI - (useWithStale GetParsedModule nfp) -- [x] AI - --- Step 2: find the import declaration whose alias span contains the cursor. -- [x] AI --- -- [x] AI --- We support two types of cursor positions: -- [x] AI --- 2a. The cursor is on the alias token in an import declaration, -- [x] AI --- e.g. on `Ls` in `import Data.List as Ls`. -- [x] AI --- 2b. The cursor is on the qualifier of a use site, -- [x] AI --- e.g. on `Ls` in `Ls.sort`. -- [x] AI --- Both return the same (ModuleName, RealSrcSpan): the alias name and its -- [x] AI --- span in the import declaration, for use in steps 3-5. -- [x] AI --- The callers try 2a first via (<|>), then 2b. -- [x] AI - --- | Given a cursor position that falls on the @Alias@ token in an -- [x] AI --- @import M as Alias@ declaration (not on a use site such as @Alias.foo@), -- [x] AI --- return the alias 'ModuleName' and the 'RealSrcSpan' of that token. -- [x] AI --- Returns 'Nothing' if no import alias covers the cursor position. -- [x] AI --- Multiple imports of the same module with different aliases are handled -- [x] AI --- correctly because we match on the cursor position, not the module name. -- [x] AI -findImportAliasDeclAtPos -- [x] AI - :: Position -- [x] AI - -> [LImportDecl GhcPs] -- [x] AI - -> Maybe (ModuleName, RealSrcSpan) -- [x] AI -findImportAliasDeclAtPos pos imports = listToMaybe -- [x] AI - [ (aliasName, aliasDeclSpan) -- [x] AI - | _locatedImport@(L _ decl) <- imports -- [x] AI - , Just locatedAlias <- [ideclAs decl] -- [x] AI - , let aliasName = unLoc locatedAlias -- [x] AI - , RealSrcSpan aliasDeclSpan _ <- [locA locatedAlias] -- [x] AI - , rangeContainsPosition (realSrcSpanToRange aliasDeclSpan) pos -- [x] AI - ] -- [x] AI - --- | Given a cursor position that falls on the qualifier of a use site -- [x] AI --- (e.g. on @Ls@ in @Ls.sort@), find the import declaration that introduced -- [x] AI --- that alias and return the alias 'ModuleName' and the 'RealSrcSpan' of the -- [x] AI --- alias token in that declaration (e.g. @Ls@ in @import Data.List as Ls@). -- [x] AI --- Returns 'Nothing' if the cursor is not on a qualifier that corresponds to -- [x] AI --- any import alias. -- [x] AI -findImportAliasUseAtPos -- [x] AI - :: Position -- [x] AI - -> [LHsDecl GhcPs] -- [x] AI - -> [LImportDecl GhcPs] -- [x] AI - -> Maybe (ModuleName, RealSrcSpan) -- [x] AI -findImportAliasUseAtPos pos decls imports = do -- [x] AI - aliasAtPos <- listToMaybe -- [x] AI - [ moduleAlias -- [x] AI - | L (ann :: Anno RdrName) (Qual moduleAlias _) <- listify (const True) decls -- [x] AI - , RealSrcSpan useSiteSpan _ <- [locA ann] -- [x] AI - , rangeContainsPosition (realSrcSpanToRange useSiteSpan) pos -- [x] AI - , let aliasLength = fromIntegral (length (moduleNameString moduleAlias)) -- [x] AI - start = realSrcSpanStart useSiteSpan -- [x] AI - line = fromIntegral (srcLocLine start) -- [x] AI - startColumn = fromIntegral (srcLocCol start) -- [x] AI - aliasRange = Range -- [x] AI - (Position (line - 1) (startColumn - 1)) -- [x] AI - (Position (line - 1) (startColumn - 1 + aliasLength)) -- [x] AI - , rangeContainsPosition aliasRange pos -- [x] AI - ] -- [x] AI - listToMaybe -- [x] AI - [ (aliasName, aliasDeclSpan) -- [x] AI - | _locatedImport@(L _ decl) <- imports -- [x] AI - , Just locatedAlias <- [ideclAs decl] -- [x] AI - , let aliasName = unLoc locatedAlias -- [x] AI - , aliasName == aliasAtPos -- [x] AI - , RealSrcSpan aliasDeclSpan _ <- [locA locatedAlias] -- [x] AI - ] -- [x] AI - --- | Check whether a 'Range' contains a 'Position' -- [x] AI --- (inclusive start, exclusive end). -- [x] AI -rangeContainsPosition :: Range -> Position -> Bool -- [x] AI -rangeContainsPosition (Range (Position sl sc) (Position el ec)) (Position l c) -- [x] AI - = (l > sl || (l == sl && c >= sc)) -- [x] AI - && (l < el || (l == el && c < ec)) -- [x] AI - --- Step 3: collect use-site spans for every `Qual oldAlias _` RdrName. -- [x] AI --- -- [x] AI --- We use SYB's `listify` to collect all located RdrName nodes anywhere in -- [x] AI --- the declaration list, then filter to those whose qualifier matches the -- [x] AI --- target alias. Each matching node contributes its RealSrcSpan. -- [x] AI - --- | Collect the 'RealSrcSpan' of every qualified use of @oldAlias@ in the -- [x] AI --- given declarations, e.g. every occurrence of @L.foo@, @L.bar@, etc. -- [x] AI --- Uses SYB 'listify' to traverse the full 'GhcPs' AST. -- [x] AI -importAliasUseSiteSpans -- [x] AI - :: ModuleName -- [x] AI - -> [LHsDecl GhcPs] -- [x] AI - -> [RealSrcSpan] -- [x] AI -importAliasUseSiteSpans oldAlias decls = -- [x] AI - [ rsp -- [x] AI - | L (ann :: Anno RdrName) (Qual moduleAlias _) <- listify (const True) decls -- [x] AI - , moduleAlias == oldAlias -- [x] AI - , RealSrcSpan rsp _ <- [locA ann] -- [x] AI - ] -- [x] AI - --- Step 4: build TextEdits — one for the import alias declaration and one -- [x] AI --- for each qualifier at a use site. -- [x] AI - --- The two helpers below are kept separate -- [x] AI --- because the span arithmetic differs: use-site spans cover the full -- [x] AI --- qualified name (e.g. @L.foo@) so we must truncate to the qualifier width, -- [x] AI --- whereas import spans already cover exactly the alias token. -- [x] AI - --- | Build a 'TextEdit' that replaces the qualifier portion of a use site. -- [x] AI --- The span covers only the alias (e.g. the @L@ in @L.foo@), not the dot -- [x] AI --- or the following name. Column arithmetic uses GHC's 1-based source -- [x] AI --- locations and converts to the 0-based LSP 'Position' convention. -- [x] AI -importAliasUseSiteEdit -- [x] AI - :: ModuleName -- ^ old alias, used to compute the qualifier width -- [x] AI - -> T.Text -- ^ new alias text -- [x] AI - -> RealSrcSpan -- ^ span of the full qualified name, e.g. @L.foo@ -- [x] AI - -> TextEdit -- [x] AI -importAliasUseSiteEdit oldAlias newAlias rsp = TextEdit range newAlias -- [x] AI - where -- [x] AI - start = realSrcSpanStart rsp -- [x] AI - line = fromIntegral (srcLocLine start) - 1 -- [x] AI - startCol = fromIntegral (srcLocCol start) - 1 -- [x] AI - -- The qualifier occupies exactly as many characters as the alias string. -- [x] AI - -- The dot is at startCol + width and is not included in the edit. -- [x] AI - endCol = startCol + fromIntegral (length (moduleNameString oldAlias)) -- [x] AI - range = Range (Position line startCol) (Position line endCol) -- [x] AI - --- | Build a 'TextEdit' that replaces the alias token in an -- [x] AI --- @import M as Alias@ declaration. -- [x] AI --- The span is taken directly from 'ideclAs' and already covers exactly -- [x] AI --- the alias token, so no column arithmetic is needed. -- [x] AI -importAliasDeclEdit -- [x] AI - :: T.Text -- ^ new alias text -- [x] AI - -> RealSrcSpan -- ^ span of @Alias@ in @import M as Alias@ -- [x] AI - -> TextEdit -- [x] AI -importAliasDeclEdit newAlias rsp = TextEdit (realSrcSpanToRange rsp) newAlias -- [x] AI - --- Step 5: assemble WorkspaceEdit and wire into prepareRenameProvider and -- [x] AI --- renameProvider. -- [x] AI --- -- [x] AI --- Both handlers share the same structure: fetch the parsed module, check -- [x] AI --- whether the cursor is on an alias token, and either take the alias path -- [x] AI --- or fall through to name-based rename. -- [x] AI --- -- [x] AI --- In renameProvider, the alias path delegates to aliasBasedRename, which -- [x] AI --- collects use-site spans via importAliasUseSiteSpans, builds a TextEdit for the -- [x] AI --- import declaration via importAliasDeclEdit and one per use site via -- [x] AI --- importAliasUseSiteEdit, and wraps them all in a WorkspaceEdit. The name-based -- [x] AI --- fallthrough delegates to nameBasedRename. -- [x] AI --- -- [x] AI --- In prepareRenameProvider, the alias path returns -- [x] AI --- PrepareRenameDefaultBehavior True if an alias is found. If not, the name-based fallthrough -- [x] AI --- returns PrepareRenameDefaultBehavior True or False depending on whether -- [x] AI --- getNamesAtPos finds any Names at the cursor. -- [x] AI --- -- [x] AI --- Design decision 1 — entry point: a branch inside the existing handlers -- [x] AI --- rather than a separate handler. HLS only registers one rename handler -- [x] AI --- per plugin, so a separate handler is not viable. The alias branch is -- [x] AI --- checked first in both handlers; if the cursor is not on an alias token, -- [x] AI --- we fall through to name-based rename. -- [x] AI --- -- [x] AI --- Design decision 2 — prepareRenameProvider return value: the alias branch -- [x] AI --- returns PrepareRenameDefaultBehavior True, consistent with the existing -- [x] AI --- handler. PrepareRenameResult also supports returning an explicit range -- [x] AI --- and placeholder text (the InL and InR (InL ...) variants), which would -- [x] AI --- let the client highlight exactly the alias token and pre-fill the current -- [x] AI --- alias text in the rename box. -- [x] AI --- TODO: use those variants for a better UX. -- [x] AI --- -- [x] AI --- Design decision 3 — getParsedModuleStale returns Nothing: fail early -- [x] AI --- with an informative error. Falling through to name-based rename is not a -- [x] AI --- real fallback since that path also requires a typechecked module and will -- [x] AI --- fail anyway. Nothing only occurs if the file has never been successfully -- [x] AI --- parsed. -- [x] AI - --------------------------------------------------------------------------------------------------- -- Source renaming diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs index 0c77898dc5..6d12badea3 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs @@ -39,7 +39,7 @@ module Ide.Plugin.Rename.ImportAlias import Control.Lens ((^.)) import Control.Monad (guard) -import Control.Monad.Except (ExceptT, throwError) +import Control.Monad.Except (ExceptT) import Control.Monad.IO.Class (MonadIO, liftIO) import Data.Generics import qualified Data.Map as M From bdb70401bc4dcb7257c569d901380b03bb721f5a Mon Sep 17 00:00:00 2001 From: izuzu Date: Thu, 19 Mar 2026 17:53:44 +0800 Subject: [PATCH 19/31] Add tests for alias used by multiple modules --- plugins/hls-rename-plugin/test/Main.hs | 12 ++++++++---- ...edAsAlias.expected.hs => ImportAlias.expected.hs} | 0 .../testdata/{QualifiedAsAlias.hs => ImportAlias.hs} | 0 .../test/testdata/ImportAliasShared.expected.hs | 5 +++++ .../test/testdata/ImportAliasShared.hs | 5 +++++ plugins/hls-rename-plugin/test/testdata/hie.yaml | 3 ++- 6 files changed, 20 insertions(+), 5 deletions(-) rename plugins/hls-rename-plugin/test/testdata/{QualifiedAsAlias.expected.hs => ImportAlias.expected.hs} (100%) rename plugins/hls-rename-plugin/test/testdata/{QualifiedAsAlias.hs => ImportAlias.hs} (100%) create mode 100644 plugins/hls-rename-plugin/test/testdata/ImportAliasShared.expected.hs create mode 100644 plugins/hls-rename-plugin/test/testdata/ImportAliasShared.hs diff --git a/plugins/hls-rename-plugin/test/Main.hs b/plugins/hls-rename-plugin/test/Main.hs index 6913532143..c1eb7f6655 100644 --- a/plugins/hls-rename-plugin/test/Main.hs +++ b/plugins/hls-rename-plugin/test/Main.hs @@ -48,6 +48,14 @@ renameTests = testGroup "Identifier" rename doc (Position 6 37) "Expr" , goldenWithRename "Hidden function" "HiddenFunction" $ \doc -> rename doc (Position 0 32) "quux" + , goldenWithRename "Import alias in declaration" "ImportAlias" $ \doc -> + rename doc (Position 1 24) "G" + , goldenWithRename "Import alias in use" "ImportAlias" $ \doc -> + rename doc (Position 4 6) "G" + , goldenWithRename "Import alias (shared) in declaration" "ImportAliasShared" $ \doc -> + rename doc (Position 1 31) "Maybe" + , goldenWithRename "Import alias (shared) in use" "ImportAliasShared" $ \doc -> + rename doc (Position 4 6) "Maybe" , goldenWithRename "Imported function" "ImportedFunction" $ \doc -> rename doc (Position 3 8) "baz" , goldenWithRename "Import hiding" "ImportHiding" $ \doc -> @@ -56,10 +64,6 @@ renameTests = testGroup "Identifier" rename doc (Position 4 23) "blah" , goldenWithRename "Let expression" "LetExpression" $ \doc -> rename doc (Position 5 11) "foobar" - , goldenWithRename "Qualified-as alias in import" "QualifiedAsAlias" $ \doc -> - rename doc (Position 1 24) "G" - , goldenWithRename "Qualified-as alias in use" "QualifiedAsAlias" $ \doc -> - rename doc (Position 4 6) "G" , goldenWithRename "Qualified-as function" "QualifiedAsFunction" $ \doc -> rename doc (Position 3 10) "baz" , goldenWithRename "Qualified shadowing" "QualifiedShadowing" $ \doc -> diff --git a/plugins/hls-rename-plugin/test/testdata/QualifiedAsAlias.expected.hs b/plugins/hls-rename-plugin/test/testdata/ImportAlias.expected.hs similarity index 100% rename from plugins/hls-rename-plugin/test/testdata/QualifiedAsAlias.expected.hs rename to plugins/hls-rename-plugin/test/testdata/ImportAlias.expected.hs diff --git a/plugins/hls-rename-plugin/test/testdata/QualifiedAsAlias.hs b/plugins/hls-rename-plugin/test/testdata/ImportAlias.hs similarity index 100% rename from plugins/hls-rename-plugin/test/testdata/QualifiedAsAlias.hs rename to plugins/hls-rename-plugin/test/testdata/ImportAlias.hs diff --git a/plugins/hls-rename-plugin/test/testdata/ImportAliasShared.expected.hs b/plugins/hls-rename-plugin/test/testdata/ImportAliasShared.expected.hs new file mode 100644 index 0000000000..93549824ea --- /dev/null +++ b/plugins/hls-rename-plugin/test/testdata/ImportAliasShared.expected.hs @@ -0,0 +1,5 @@ +import qualified Control.Monad as M +import qualified Data.Maybe as Maybe + +bar :: Maybe a -> Bool +bar = Maybe.isJust diff --git a/plugins/hls-rename-plugin/test/testdata/ImportAliasShared.hs b/plugins/hls-rename-plugin/test/testdata/ImportAliasShared.hs new file mode 100644 index 0000000000..133efe693f --- /dev/null +++ b/plugins/hls-rename-plugin/test/testdata/ImportAliasShared.hs @@ -0,0 +1,5 @@ +import qualified Control.Monad as M +import qualified Data.Maybe as M + +bar :: Maybe a -> Bool +bar = M.isJust diff --git a/plugins/hls-rename-plugin/test/testdata/hie.yaml b/plugins/hls-rename-plugin/test/testdata/hie.yaml index 614f7e49ce..643c3acca7 100644 --- a/plugins/hls-rename-plugin/test/testdata/hie.yaml +++ b/plugins/hls-rename-plugin/test/testdata/hie.yaml @@ -9,11 +9,12 @@ cradle: - "FunctionName" - "Gadt" - "HiddenFunction" + - "ImportAlias" + - "ImportAliasShared" - "ImportHiding" - "ImportedFunction" - "IndirectPuns" - "LetExpression" - - "QualifiedAsAlias" - "QualifiedAsFunction" - "QualifiedFunction" - "QualifiedShadowing" From f802a14e36e74337967ab13a45f0f6db70090680 Mon Sep 17 00:00:00 2001 From: izuzu Date: Sun, 22 Mar 2026 19:37:36 +0800 Subject: [PATCH 20/31] Use recommended GHC API to ensure compatibility This commit ensures compatibility with GHC 9.6.7 and later by using GHC's recommended API, including: - The `XRec` type family, `unLoc`, and `getLoc` for working with annotated AST nodes - `importSpecModule` for extracting module names from `ImportSpec` elements - `pickGREs` to select `GlobalRdrEnv` elements matching a given import alias --- .../src/Ide/Plugin/Rename/ImportAlias.hs | 72 ++++++++++--------- 1 file changed, 38 insertions(+), 34 deletions(-) diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs index 6d12badea3..0b0e3f41cf 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs @@ -87,12 +87,12 @@ findImportAliasDeclAtPos -> Maybe ImportAlias findImportAliasDeclAtPos pos imports = listToMaybe [ ImportAlias {aliasModuleName, aliasName, aliasDeclSpan} - | _locatedImport@(L _ decl) <- imports - , Just locatedAlias <- [ideclAs decl] - , let aliasName = unLoc locatedAlias - , RealSrcSpan aliasDeclSpan _ <- [locA locatedAlias] + | importDecl <- map unLoc imports + , Just locatedAlias <- [ideclAs importDecl] + , RealSrcSpan aliasDeclSpan _ <- [getLocA locatedAlias] , rangeContainsPosition (realSrcSpanToRange aliasDeclSpan) pos - , let aliasModuleName = unLoc (ideclName decl) + , let aliasName = unLoc locatedAlias + aliasModuleName = unLoc (ideclName importDecl) ] -- | Find the 'ImportAlias' matching the name qualifier at the cursor, such as @@ -106,8 +106,9 @@ findImportAliasUseAtPos findImportAliasUseAtPos pos decls imports = case listToMaybe [ qualifier - | L (ann :: Anno RdrName) (Qual qualifier _) <- listify (const True) decls - , RealSrcSpan useSiteSpan _ <- [locA ann] + | locatedRdrName :: XRec GhcPs RdrName <- listify (const True) decls + , Qual qualifier _ <- [unLoc locatedRdrName] + , RealSrcSpan useSiteSpan _ <- [getLocA locatedRdrName] , rangeContainsPosition (realSrcSpanToRange useSiteSpan) pos , let qualifierLength = fromIntegral (length (moduleNameString qualifier)) start = realSrcSpanStart useSiteSpan @@ -121,12 +122,12 @@ findImportAliasUseAtPos pos decls imports = Nothing -> [] Just qualifierAtPos -> [ ImportAlias {aliasModuleName, aliasName, aliasDeclSpan} - | _locatedImport@(L _ decl) <- imports - , Just locatedAlias <- [ideclAs decl] + | importDecl <- map unLoc imports + , Just locatedAlias <- [ideclAs importDecl] , let aliasName = unLoc locatedAlias , aliasName == qualifierAtPos - , RealSrcSpan aliasDeclSpan _ <- [locA locatedAlias] - , let aliasModuleName = unLoc (ideclName decl) + , RealSrcSpan aliasDeclSpan _ <- [getLocA locatedAlias] + , let aliasModuleName = unLoc (ideclName importDecl) ] -- | Return the module name and declaration span for the alias being renamed at @@ -171,9 +172,9 @@ aliasBasedRename state nfp uri importAlias imports decls newNameText = do declSpan = aliasDeclSpan importAlias duplicateAlias = length [ () - | L _ decl <- imports - , Just locAlias <- [ideclAs decl] - , unLoc locAlias == oldAlias + | importDecl <- map unLoc imports + , Just locatedAlias <- [ideclAs importDecl] + , unLoc locatedAlias == oldAlias ] > 1 useSiteSpans <- if duplicateAlias @@ -198,9 +199,10 @@ importAliasUseSiteSpans -> [RealSrcSpan] importAliasUseSiteSpans importAlias decls = [ fullNameSpan - | L (ann :: Anno RdrName) (Qual moduleAlias _) <- listify (const True) decls - , moduleAlias == aliasName importAlias - , RealSrcSpan fullNameSpan _ <- [locA ann] + | locatedRdrName :: XRec GhcPs RdrName <- listify (const True) decls + , Qual qualifier _ <- [unLoc locatedRdrName] + , qualifier == aliasName importAlias + , RealSrcSpan fullNameSpan _ <- [getLocA locatedRdrName] ] -- | Build a 'TextEdit' replacing the qualifier part in a qualified name (like @@ -255,11 +257,11 @@ disambiguateAliasAtPos state nfp namesAtPos imports = do gre <- maybeToList (lookupGRE_Name rdrEnv name) impSpec <- gre_imp gre let declSpec = is_decl impSpec - specModuleName = moduleName (is_mod declSpec) + specModuleName = importSpecModule impSpec specAlias = is_as declSpec - L _ decl <- imports - guard (unLoc (ideclName decl) == specModuleName) - Just locatedAlias <- [ideclAs decl] + importDecl <- map unLoc imports + guard (unLoc (ideclName importDecl) == specModuleName) + Just locatedAlias <- [ideclAs importDecl] RealSrcSpan aliasDeclSpan _ <- [locA locatedAlias] pure (ImportAlias specModuleName specAlias aliasDeclSpan) @@ -276,16 +278,17 @@ importAliasUseSiteSpansDisambiguated importAliasUseSiteSpansDisambiguated state nfp importAlias decls = do tcModule <- runActionE "rename.useSiteSpans" state (useE TypeCheck nfp) let rdrEnv = tcg_rdr_env (tmrTypechecked tcModule) - allSpans = importAliasUseSiteSpansWithOcc (aliasName importAlias) decls - ImportAlias actualMod _ _ = importAlias - pure - [ rsp - | (occName, rsp) <- allSpans - , gre <- lookupGRE rdrEnv $ - LookupRdrName (Qual (aliasName importAlias) occName) AllRelevantGREs - , impSpec <- gre_imp gre - , moduleName (is_mod (is_decl impSpec)) == actualMod - ] + ImportAlias{aliasModuleName, aliasName} = importAlias + allSpans = importAliasUseSiteSpansWithOcc aliasName decls + maybeMatchingSpan (occName, rsp) = + let rdrName = Qual aliasName occName + in listToMaybe + [ rsp + | gre <- pickGREs rdrName $ lookupGlobalRdrEnv rdrEnv occName + , impSpec <- gre_imp gre + , importSpecModule impSpec == aliasModuleName + ] + pure $ mapMaybe maybeMatchingSpan allSpans -- | Like 'importAliasUseSiteSpans' but also returns the 'OccName' of each -- use, needed for 'GlobalRdrEnv' lookup in the disambiguated path. @@ -295,7 +298,8 @@ importAliasUseSiteSpansWithOcc -> [(OccName, RealSrcSpan)] importAliasUseSiteSpansWithOcc oldAlias decls = [ (occName, rsp) - | L (ann :: Anno RdrName) (Qual moduleAlias occName) <- listify (const True) decls - , moduleAlias == oldAlias - , RealSrcSpan rsp _ <- [locA ann] + | locatedRdrName :: XRec GhcPs RdrName <- listify (const True) decls + , Qual qualifier occName <- [unLoc locatedRdrName] + , qualifier == oldAlias + , RealSrcSpan rsp _ <- [getLocA locatedRdrName] ] From f97e95f15940cd037b0075076bbd4b7cf8bcff61 Mon Sep 17 00:00:00 2001 From: izuzu Date: Sun, 22 Mar 2026 20:27:51 +0800 Subject: [PATCH 21/31] Handle ambiguous alias due to module re-export When a module re-exports another, and both are then imported using the same alias, it is ambiguous which import should have its alias renamed (for example, `L.view` with both `import Control.Lens as L` and `import Control.Lens.Getter as L`). With this commit, the plugin now throws an error and suggests that the user resolve the ambiguity by renaming the alias in one of the matching import declarations instead. --- NOTES.md | 62 +++++++++++++----- .../src/Ide/Plugin/Rename/ImportAlias.hs | 64 ++++++++++++------- plugins/hls-rename-plugin/test/Main.hs | 26 ++++++-- .../test/testdata/ImportAlias.expected.hs | 3 +- .../test/testdata/ImportAlias.hs | 3 +- .../testdata/ImportAliasReexport.expected.hs | 11 ++++ .../test/testdata/ImportAliasReexport.hs | 11 ++++ .../hls-rename-plugin/test/testdata/hie.yaml | 1 + 8 files changed, 132 insertions(+), 49 deletions(-) create mode 100644 plugins/hls-rename-plugin/test/testdata/ImportAliasReexport.expected.hs create mode 100644 plugins/hls-rename-plugin/test/testdata/ImportAliasReexport.hs diff --git a/NOTES.md b/NOTES.md index 528ba6dea1..150ad1afcb 100644 --- a/NOTES.md +++ b/NOTES.md @@ -1,9 +1,8 @@ # Notes -This branch is for experimenting and attempting to implement renaming qualified-as aliases. For example: +This branch is for experimenting and attempting to implement renaming import aliases. For example: ```haskell - -- Before: ---------------------------- import qualified Data.List as L @@ -21,22 +20,27 @@ bar = List.take The author uses generative AI (specifically, Claude Sonnet 4.6) to understand key concepts and draft code. -*The author has reviewed and understood all AI-generated content introduced in this branch, and can personally vouch for and explain it if needed.* +*The author has reviewed and understood every line of AI-generated content in this branch, personally vouches for it, and can explain any part of it if needed.* All notes generated through Claude are marked as such. ## How renaming currently works -When the user hovers the cursor over `L` or `L.take`, the `hls-rename-plugin` consults GHC’ AST and determines the identifier at point. An identifier is of type `Identifier`, defined as `type Identifier = Either ModuleName Name`. +`hls-rename-plugin` currently doesn’t handle import aliases. + +Consider the `-- Before: --` example above. When the user places the cursor on either occurrence of `L`, `hls-rename-plugin` consults the HIE AST and finds the identifier at the cursor. + +- In `import qualified Data.List as L`, `L` is a `ModuleName` identifier. + + The plugin currently doesn’t consider `ModuleName` identifiers renameable, so it responds with “No symbol to rename at given location.” -- In `import qualified Data.List as L`, `L` is considered a `ModuleName`. -- In `L.take`, the entirety of `L.take` is considered a single `Name`. The AST records the name as external, coming from the `Data.List` module (not the `L` module, because the name is resolved). +- In `L.take`, the entirety of `L.take` is a single `Name` identifier. -The plugin only considers `Name` identifiers as eligible for renaming. Therefore, if the user hovers over `L` in `import qualified Data.List as L`, the plugin says “No symbol to rename at given location.” + The AST records the name as external, coming from the `Data.List` module (not `L`, because the import alias is resolved). Information about the alias `L` is therefore lost. -Also, a `Name` records the module in which the identifier is *defined*, not the module that the identifier is imported from. This can cause problems, especially for modules that re-export other modules. + Also, a `Name` records the module in which the identifier is *defined*, not the module that the identifier is imported from. This can cause problems, especially for modules that re-export other modules. -## Possible approach +## Initial approach 1. When the user hovers over `L.take`, use the HIE AST to get the full span of the identifier. 2. Use [`GHC.Parser`](https://hackage-content.haskell.org/package/ghc-9.12.1/docs/GHC-Parser.html) to parse the identifier into a `RdrName` of the form `Qual ModuleName OccName` and obtain the module alias as listed in the import section. @@ -62,20 +66,40 @@ Also, a `Name` records the module in which the identifier is *defined*, not the 1. Using the parsed AST instead of the full HIE AST allows us to inspect `RdrName` identifiers, which contain unresolved import module aliases. It also turns out that this is already implemented as a rule in HLS. -2. The cursor can be on either an import alias declaration (such as `Ls` in `import Data.List as Ls`) or a use site (such as `Ls` in `Ls.take`). +2. The cursor can be on either an import alias declaration (such as `L` in `import Data.List as L`) or a use site (such as `L` in `L.take`). - - FIXME: Targeting `L` in `L.take` in this example causes the wrong alias to be renamed: + - Common case (fast): If only one module is imported as `L`, then all renaming is done through the parsed AST, which is fast. - ``` haskell - import Control.Lens as L - import Data.List as L + - Special case (slow, but rare): If multiple modules are imported as `L`, then `hls-rename-plugin` consults the typechecked AST and `GlobalRdrEnv` to find which imported module is referred to. - f = L.take + For example, targeting `L` in `L.take` in this example leaves the `Control.Lens` import unchanged: + + ```haskell + import Control.Lens as L -- > import Control.Lens as L + import Data.List as L -- > import Data.List as List + -- > + f = L.take -- > f = List.take ``` - Relevant link: [GHC wiki page](https://gitlab.haskell.org/ghc/ghc/-/wikis/commentary/compiler/renamer?version_id=9dccaa3e023565a2ef5091b4a08da847872714ff) + Relevant link: [GHC wiki page on the renamer](https://gitlab.haskell.org/ghc/ghc/-/wikis/commentary/compiler/renamer?version_id=9dccaa3e023565a2ef5091b4a08da847872714ff) + + - NOTE: If any import declarations mention a module not found in any dependencies (like `Control.Lens` without `lens`), then typechecking fails, and the plugin can’t disambiguate the alias. This case should result in a meaningful error message. + + This also means disambiguation can only happen after typechecking is complete. (Also, typechecking and renaming can be thought of as happening in the same pass.[^1] [^2]) -3. Traversing the AST is done using `listify` from `syb`. `listify` needs to be monomorphic. To apply the correct type, use the `Anno` type family. + - ~~TODO~~ DONE: Handle when both a module and its re-exporter are imported, like this: + + ```haskell + import Control.Lens as L + import Control.Lens.Getter as L + + f = L.view + ``` + + - If the cursor is on `L` in `L.view`, then throw an error like “Can’t rename: There are 2 matching imports with the alias 'L'. Click on one of these 'L' aliases in the import declarations and try renaming again.” + - If the cursor is on `L` in either `as L` declaration, then rename all entities exported by the corresponding module (including re-exported ones). + +3. Traversing the AST is done using `listify` from `syb`. `listify` needs to be monomorphic. To apply the correct type, use the `XRec` and `Anno` type families. 4. GHC uses 1-based positioning; LSP uses 0-based positioning. @@ -100,3 +124,7 @@ Also, a `Name` records the module in which the identifier is *defined*, not the TODO: In general, `PrepareRenameResult` feels underutilized. 3. Fail early if HLS can’t get the parsed module, instead of falling through. The existing renaming logic also needs the module to be parsed (and then typechecked) anyway. + +[^1]: https://ghc-proposals.readthedocs.io/en/latest/proposals/0107-source-plugins.html + +[^2]: https://downloads.haskell.org/ghc/latest/docs/users_guide/extending_ghc.html diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs index 0b0e3f41cf..65a6243cf2 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs @@ -1,4 +1,5 @@ -{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE OverloadedStrings #-} {-| Logic for renaming qualified import aliases. @@ -38,8 +39,8 @@ module Ide.Plugin.Rename.ImportAlias ) where import Control.Lens ((^.)) -import Control.Monad (guard) -import Control.Monad.Except (ExceptT) +import Control.Monad.Except (ExceptT, + MonadError (throwError)) import Control.Monad.IO.Class (MonadIO, liftIO) import Data.Generics import qualified Data.Map as M @@ -152,9 +153,29 @@ resolveAliasAtPos getNamesAtPosFn state nfp pos decls imports = Nothing -> case findImportAliasUseAtPos pos decls imports of [] -> pure Nothing [result] -> pure (Just result) - _many -> do + candidates -> do namesAtPos <- getNamesAtPosFn state nfp pos - disambiguateAliasAtPos state nfp namesAtPos imports + disambiguated <- disambiguateAliasUse state nfp namesAtPos candidates + case disambiguated of + [] -> pure Nothing + [result] -> pure (Just result) + aliases -> throwError $ PluginInvalidParams $ + ambiguousAliasErrorMessage aliases + where + ambiguousAliasErrorMessage [] = "" + ambiguousAliasErrorMessage [_] = "" + ambiguousAliasErrorMessage aliases@(alias1 : alias2 : _) = + let aliasCount = T.show (length aliases) + aliasText = T.pack (moduleNameString (aliasName alias1)) + module1 = T.pack (moduleNameString (aliasModuleName alias1)) + module2 = T.pack (moduleNameString (aliasModuleName alias2)) + quote t = "‘" <> t <> "’" + in ("Alias " <> quote aliasText + <> " is ambiguous (matching " <> aliasCount + <> " imports, including " + <> quote module1 <> " and " <> quote module2 + <> "). Try renaming " <> quote aliasText + <> " in one of these import declarations directly.") -- | Build a 'WorkspaceEdit' renaming an import alias and all its use sites. aliasBasedRename @@ -240,30 +261,27 @@ rangeContainsPosition (Range (Position sl sc) (Position el ec)) (Position l c) -- | Resolve an ambiguous alias use site by consulting the typechecked -- module's 'GlobalRdrEnv'. Used when multiple imports share the same alias. --- The caller is responsible for providing the names under the cursor. --- TODO: Rename it to @disambiguateAliasUseAtPos@. -disambiguateAliasAtPos +-- The caller is responsible for providing the names at the cursor. +-- Returns multiple results if they both export the same name (such as @L.view@ +-- with both @Control.Lens as L@ and @Control.Lens.Getter as L@). +disambiguateAliasUse :: MonadIO m => IdeState -> NormalizedFilePath -> [Name] - -> [LImportDecl GhcPs] - -> ExceptT PluginError m (Maybe ImportAlias) -disambiguateAliasAtPos state nfp namesAtPos imports = do + -> [ImportAlias] + -> ExceptT PluginError m [ImportAlias] +disambiguateAliasUse state nfp namesAtPos candidates = do tcModule <- runActionE "rename.disambiguateAlias" state (useE TypeCheck nfp) let rdrEnv = tcg_rdr_env (tmrTypechecked tcModule) - pure $ listToMaybe $ do - name <- namesAtPos - gre <- maybeToList (lookupGRE_Name rdrEnv name) - impSpec <- gre_imp gre - let declSpec = is_decl impSpec - specModuleName = importSpecModule impSpec - specAlias = is_as declSpec - importDecl <- map unLoc imports - guard (unLoc (ideclName importDecl) == specModuleName) - Just locatedAlias <- [ideclAs importDecl] - RealSrcSpan aliasDeclSpan _ <- [locA locatedAlias] - pure (ImportAlias specModuleName specAlias aliasDeclSpan) + pure + [ candidate + | name <- namesAtPos + , globalRdrEnvElement <- maybeToList (lookupGRE_Name rdrEnv name) + , importSpec <- gre_imp globalRdrEnvElement + , candidate@ImportAlias{aliasModuleName} <- candidates + , importSpecModule importSpec == aliasModuleName + ] -- | Like 'importAliasUseSiteSpans' but filters to use sites that resolve -- to names from @actualMod@, using the typechecked module's 'GlobalRdrEnv'. diff --git a/plugins/hls-rename-plugin/test/Main.hs b/plugins/hls-rename-plugin/test/Main.hs index c1eb7f6655..7051efd8b2 100644 --- a/plugins/hls-rename-plugin/test/Main.hs +++ b/plugins/hls-rename-plugin/test/Main.hs @@ -8,7 +8,7 @@ import Control.Lens ((^.)) import Data.Aeson import Data.Functor (void) import qualified Data.Map as M -import Data.Text (Text, pack) +import Data.Text (Text, isInfixOf, pack, unpack) import Ide.Plugin.Config import qualified Ide.Plugin.Rename as Rename import qualified Language.LSP.Protocol.Lens as L @@ -48,14 +48,26 @@ renameTests = testGroup "Identifier" rename doc (Position 6 37) "Expr" , goldenWithRename "Hidden function" "HiddenFunction" $ \doc -> rename doc (Position 0 32) "quux" - , goldenWithRename "Import alias in declaration" "ImportAlias" $ \doc -> - rename doc (Position 1 24) "G" - , goldenWithRename "Import alias in use" "ImportAlias" $ \doc -> - rename doc (Position 4 6) "G" - , goldenWithRename "Import alias (shared) in declaration" "ImportAliasShared" $ \doc -> + , goldenWithRename "Import alias declaration" "ImportAlias" $ \doc -> + rename doc (Position 1 14) "G" + , goldenWithRename "Import alias at use site" "ImportAlias" $ \doc -> + rename doc (Position 5 6) "G" + , goldenWithRename "Import alias declaration (shared by unrelated imports)" "ImportAliasShared" $ \doc -> rename doc (Position 1 31) "Maybe" - , goldenWithRename "Import alias (shared) in use" "ImportAliasShared" $ \doc -> + , goldenWithRename "Import alias at use site (shared by unrelated imports)" "ImportAliasShared" $ \doc -> rename doc (Position 4 6) "Maybe" + , goldenWithRename "Import alias declaration (with re-exports)" "ImportAliasReexport" $ \doc -> do + rename doc (Position 1 18) "Reexport" + , testCase "Import alias at use site (ambiguous due to re-exports)" $ runRenameSession "" $ do + doc <- openDoc "ImportAliasReexport.hs" "haskell" + expectNoMoreDiagnostics 3 doc "typecheck" + renameErr <- expectRenameError doc (Position 4 6) "G" + liftIO $ do + renameErr ^. L.code @?= InR ErrorCodes_InvalidParams + let errMessage = renameErr ^. L.message + assertBool + ("expected error due to ambiguous alias, but got: " <> unpack errMessage) + ("Alias ‘F’ is ambiguous" `isInfixOf` errMessage) , goldenWithRename "Imported function" "ImportedFunction" $ \doc -> rename doc (Position 3 8) "baz" , goldenWithRename "Import hiding" "ImportHiding" $ \doc -> diff --git a/plugins/hls-rename-plugin/test/testdata/ImportAlias.expected.hs b/plugins/hls-rename-plugin/test/testdata/ImportAlias.expected.hs index 034019788a..2a0a8c3f38 100644 --- a/plugins/hls-rename-plugin/test/testdata/ImportAlias.expected.hs +++ b/plugins/hls-rename-plugin/test/testdata/ImportAlias.expected.hs @@ -1,5 +1,6 @@ import Foo ((!)) -import qualified Foo as G +import Foo as G +import Missing.Module as M bar :: Int -> Int bar = G.foo diff --git a/plugins/hls-rename-plugin/test/testdata/ImportAlias.hs b/plugins/hls-rename-plugin/test/testdata/ImportAlias.hs index ffba6cf2c9..af4bb0c0c7 100644 --- a/plugins/hls-rename-plugin/test/testdata/ImportAlias.hs +++ b/plugins/hls-rename-plugin/test/testdata/ImportAlias.hs @@ -1,5 +1,6 @@ import Foo ((!)) -import qualified Foo as F +import Foo as F +import Missing.Module as M bar :: Int -> Int bar = F.foo diff --git a/plugins/hls-rename-plugin/test/testdata/ImportAliasReexport.expected.hs b/plugins/hls-rename-plugin/test/testdata/ImportAliasReexport.expected.hs new file mode 100644 index 0000000000..798d0a15c9 --- /dev/null +++ b/plugins/hls-rename-plugin/test/testdata/ImportAliasReexport.expected.hs @@ -0,0 +1,11 @@ +import Data.Foldable as F +import Prelude as Reexport + +baz :: Foldable t => (a -> b -> b) -> b -> t a -> b +baz = Reexport.foldr + +bar :: (Foldable t, Applicative f) => (a -> f b) -> t a -> f () +bar = F.traverse_ + +bux :: Foldable t => (b -> a -> b) -> b -> t a -> b +bux = Reexport.foldl diff --git a/plugins/hls-rename-plugin/test/testdata/ImportAliasReexport.hs b/plugins/hls-rename-plugin/test/testdata/ImportAliasReexport.hs new file mode 100644 index 0000000000..30942d9bc7 --- /dev/null +++ b/plugins/hls-rename-plugin/test/testdata/ImportAliasReexport.hs @@ -0,0 +1,11 @@ +import Data.Foldable as F +import Prelude as F + +baz :: Foldable t => (a -> b -> b) -> b -> t a -> b +baz = F.foldr + +bar :: (Foldable t, Applicative f) => (a -> f b) -> t a -> f () +bar = F.traverse_ + +bux :: Foldable t => (b -> a -> b) -> b -> t a -> b +bux = F.foldl diff --git a/plugins/hls-rename-plugin/test/testdata/hie.yaml b/plugins/hls-rename-plugin/test/testdata/hie.yaml index 643c3acca7..b4d943cae4 100644 --- a/plugins/hls-rename-plugin/test/testdata/hie.yaml +++ b/plugins/hls-rename-plugin/test/testdata/hie.yaml @@ -10,6 +10,7 @@ cradle: - "Gadt" - "HiddenFunction" - "ImportAlias" + - "ImportAliasReexport" - "ImportAliasShared" - "ImportHiding" - "ImportedFunction" From b77b40f56f2dd0298f2289225fccd047c0b23893 Mon Sep 17 00:00:00 2001 From: izuzu Date: Fri, 27 Mar 2026 02:45:17 +0800 Subject: [PATCH 22/31] Use code-point positions to avoid UTF-16 issues By default, GHC source spans are in Unicode code points, while LSP positions are in UTF-16 code units. This commit ensures conversion between these two position types is correct by accounting for the text content using the virtual file system (VFS). See https://github.com/haskell/haskell-language-server/issues/2646 for more details. --- NOTES.md | 8 +- haskell-language-server.cabal | 1 + .../src/Ide/Plugin/Rename.hs | 44 +++++-- .../src/Ide/Plugin/Rename/ImportAlias.hs | 124 ++++++++++++------ .../test/testdata/ImportAlias.hs | 4 +- 5 files changed, 129 insertions(+), 52 deletions(-) diff --git a/NOTES.md b/NOTES.md index 150ad1afcb..9f2b2b93f1 100644 --- a/NOTES.md +++ b/NOTES.md @@ -101,7 +101,11 @@ Consider the `-- Before: --` example above. When the user places the cursor on e 3. Traversing the AST is done using `listify` from `syb`. `listify` needs to be monomorphic. To apply the correct type, use the `XRec` and `Anno` type families. -4. GHC uses 1-based positioning; LSP uses 0-based positioning. +4. GHC source spans are in Unicode code points; LSP uses UTF-16 code units by default. + + **Avoid any direct arithmetic between `RealSrcSpan` and `Range`.** + + Use `Language.LSP.VFS.CodePointRange` instead.[^3] 5. There are three key design questions, generated by Claude: @@ -128,3 +132,5 @@ Consider the `-- Before: --` example above. When the user places the cursor on e [^1]: https://ghc-proposals.readthedocs.io/en/latest/proposals/0107-source-plugins.html [^2]: https://downloads.haskell.org/ghc/latest/docs/users_guide/extending_ghc.html + +[^3]: https://github.com/haskell/haskell-language-server/issues/2646#issuecomment-1024990401 diff --git a/haskell-language-server.cabal b/haskell-language-server.cabal index e43749f1b6..12706e18d6 100644 --- a/haskell-language-server.cabal +++ b/haskell-language-server.cabal @@ -609,6 +609,7 @@ library hls-rename-plugin , hls-plugin-api == 2.13.0.0 , haskell-language-server:hls-refactor-plugin , lens + , lsp , lsp-types , mtl , mod diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs index 5d0e14c865..12f15c46c7 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs @@ -60,6 +60,7 @@ import Ide.Types import qualified Language.LSP.Protocol.Lens as L import Language.LSP.Protocol.Message import Language.LSP.Protocol.Types +import qualified Language.LSP.VFS as VFS instance Hashable (Mod a) where hash n = hash (unMod n) @@ -89,19 +90,21 @@ descriptor recorder pluginId = mkExactprintPluginDescriptor exactPrintRecorder $ moduleNameRecorder = cmapWithPrio LogModuleName recorder prepareRenameProvider :: PluginMethodHandler IdeState Method_TextDocumentPrepareRename -prepareRenameProvider state _pluginId (PrepareRenameParams (TextDocumentIdentifier uri) pos _progressToken) = do +prepareRenameProvider state _pluginId (PrepareRenameParams (TextDocumentIdentifier uri) lspPos _progressToken) = do nfp <- getNormalizedFilePathE uri + codePointPos <- getCodePointPosition state nfp lspPos maybeParsed <- ImportAlias.getParsedModuleStale state nfp case maybeParsed of Nothing -> throwError $ PluginInternalError - "Cannot rename: HLS has not yet parsed this module. Please wait for indexing to complete and try again." + "The module hasn’t yet been parsed. Please wait for indexing to complete and try again." Just parsed -> do let hsModule = unLoc $ pm_parsed_source parsed imports = hsmodImports hsModule decls = hsmodDecls hsModule - maybeAlias <- ImportAlias.resolveAliasAtPos getNamesAtPos state nfp pos decls imports + maybeAlias <- ImportAlias.resolveAliasAtPos + getNamesAtPos state nfp lspPos codePointPos decls imports case maybeAlias of - Just _ -> pure $ InL $ PrepareRenameResult $ InR $ InR $ PrepareRenameDefaultBehavior True -- [ ] AI + Just _ -> pure $ InL $ PrepareRenameResult $ InR $ InR $ PrepareRenameDefaultBehavior True Nothing -> do -- 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. @@ -110,27 +113,29 @@ prepareRenameProvider state _pluginId (PrepareRenameParams (TextDocumentIdentifi -- -- 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. - namesUnderCursor <- getNamesAtPos state nfp pos + namesUnderCursor <- getNamesAtPos state nfp lspPos 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 +renameProvider state pluginId (RenameParams _prog (TextDocumentIdentifier uri) lspPos newNameText) = do nfp <- getNormalizedFilePathE uri + codePointPos <- getCodePointPosition state nfp lspPos maybeParsed <- ImportAlias.getParsedModuleStale state nfp case maybeParsed of Nothing -> throwError $ PluginInternalError - "Cannot rename: HLS has not yet parsed this module. Please wait for indexing to complete and try again." -- [x] AI + "The module hasn’t yet been parsed. Please wait for indexing to complete and try again." Just parsed -> do let hsModule = unLoc $ pm_parsed_source parsed imports = hsmodImports hsModule decls = hsmodDecls hsModule - maybeAlias <- ImportAlias.resolveAliasAtPos getNamesAtPos state nfp pos decls imports + maybeAlias <- ImportAlias.resolveAliasAtPos + getNamesAtPos state nfp lspPos codePointPos decls imports case maybeAlias of Just importAlias -> ImportAlias.aliasBasedRename state nfp uri importAlias imports decls newNameText Nothing -> - nameBasedRename state pluginId nfp pos newNameText + nameBasedRename state pluginId nfp lspPos newNameText -- | Name-based rename: the original rename logic. nameBasedRename :: @@ -274,6 +279,27 @@ nameLocs name (HAR _ _ rm _ _) = --------------------------------------------------------------------------------------------------- -- Util +-- | Convert an LSP position (based on UTF-16 code units) to a position based on +-- whole Unicode code points. +getCodePointPosition :: + MonadIO m => + IdeState -> + NormalizedFilePath -> + Position -> + ExceptT PluginError m VFS.CodePointPosition +getCodePointPosition state nfp pos = do + virtualFile <- runActionE "rename.getVirtualFile" state + $ handleMaybeM (PluginInternalError ("Virtual file not found: " <> T.show nfp)) + $ getVirtualFile nfp + case VFS.positionToCodePointPosition virtualFile pos of + Nothing -> throwError $ PluginInvalidParams + "The cursor position is inside a Unicode surrogate pair." + Just codePointPosition -> pure codePointPosition + +-- TODO: 'getNamesAtPos' passes the LSP 'Position' directly to 'pointCommand', +-- which treats '_character' as a code-point column. This is incorrect for +-- files with supplementary-plane Unicode characters before the cursor. +-- Fixing it requires changes in ghcide, not here. getNamesAtPos :: MonadIO m => IdeState -> NormalizedFilePath -> Position -> ExceptT PluginError m [Name] getNamesAtPos state nfp pos = do HAR{hieAst} <- handleGetHieAst state nfp diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs index 65a6243cf2..88ddd9f546 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs @@ -23,7 +23,12 @@ The basic approach is this: using 'GlobalRdrEnv' and the typechecked AST. The common case, with each alias corresponding to one module, should be very -fast, even if the user renames multiple aliases in quick succession. +fast, even when the user renames multiple aliases in quick succession. + +NOTE: This module avoids manipulating LSP 'Position' and 'Range' values +directly, because by default these are in UTF-16 code units, while GHC source +spans are in Unicode code points. Instead, this module uses +'VFS.CodePointPosition' and 'VFS.CodePointRange'. -} module Ide.Plugin.Rename.ImportAlias ( getParsedModuleStale @@ -35,7 +40,7 @@ module Ide.Plugin.Rename.ImportAlias , importAliasUseSiteSpans , importAliasUseSiteEdit , importAliasDeclEdit - , rangeContainsPosition + , codePointRangeContainsPosition ) where import Control.Lens ((^.)) @@ -46,7 +51,6 @@ import Data.Generics import qualified Data.Map as M import Data.Maybe import qualified Data.Text as T -import Development.IDE (realSrcSpanToRange) import Development.IDE.Core.FileStore (getVersionedTextDoc) import Development.IDE.Core.PluginUtils import Development.IDE.Core.RuleTypes @@ -58,6 +62,7 @@ import Ide.Plugin.Error import qualified Language.LSP.Protocol.Lens as L import Language.LSP.Protocol.Message import Language.LSP.Protocol.Types +import qualified Language.LSP.VFS as VFS -- | The module name, alias name, and declaration span for an import alias. -- For example, @import Data.List as L@ corresponds to @@ -83,15 +88,15 @@ getParsedModuleStale state nfp = -- | Find the 'ImportAlias' for the alias declaration at the cursor, such as -- @Alias@ in @import Module as Alias@. findImportAliasDeclAtPos - :: Position + :: VFS.CodePointPosition -> [LImportDecl GhcPs] -> Maybe ImportAlias -findImportAliasDeclAtPos pos imports = listToMaybe +findImportAliasDeclAtPos codePointPos imports = listToMaybe [ ImportAlias {aliasModuleName, aliasName, aliasDeclSpan} | importDecl <- map unLoc imports , Just locatedAlias <- [ideclAs importDecl] , RealSrcSpan aliasDeclSpan _ <- [getLocA locatedAlias] - , rangeContainsPosition (realSrcSpanToRange aliasDeclSpan) pos + , codePointRangeContainsPosition (realSrcSpanToCodePointRange aliasDeclSpan) codePointPos , let aliasName = unLoc locatedAlias aliasModuleName = unLoc (ideclName importDecl) ] @@ -100,25 +105,25 @@ findImportAliasDeclAtPos pos imports = listToMaybe -- @Alias@ in @Alias.name@. -- Returns multiple values if multiple modules share the same alias. findImportAliasUseAtPos - :: Position + :: VFS.CodePointPosition -> [LHsDecl GhcPs] -> [LImportDecl GhcPs] -> [ImportAlias] -findImportAliasUseAtPos pos decls imports = +findImportAliasUseAtPos codePointPos decls imports = case listToMaybe [ qualifier | locatedRdrName :: XRec GhcPs RdrName <- listify (const True) decls , Qual qualifier _ <- [unLoc locatedRdrName] , RealSrcSpan useSiteSpan _ <- [getLocA locatedRdrName] - , rangeContainsPosition (realSrcSpanToRange useSiteSpan) pos + , codePointRangeContainsPosition (realSrcSpanToCodePointRange useSiteSpan) codePointPos , let qualifierLength = fromIntegral (length (moduleNameString qualifier)) - start = realSrcSpanStart useSiteSpan - line = fromIntegral (srcLocLine start) - startColumn = fromIntegral (srcLocCol start) - qualifierRange = Range - (Position (line - 1) (startColumn - 1)) - (Position (line - 1) (startColumn - 1 + qualifierLength)) - , rangeContainsPosition qualifierRange pos + spanStart = realSrcSpanStart useSiteSpan + line = fromIntegral (srcLocLine spanStart) - 1 + startColumn = fromIntegral (srcLocCol spanStart) - 1 + qualifierRange = VFS.CodePointRange + (VFS.CodePointPosition line startColumn) + (VFS.CodePointPosition line (startColumn + qualifierLength)) + , codePointRangeContainsPosition qualifierRange codePointPos ] of Nothing -> [] Just qualifierAtPos -> @@ -144,13 +149,14 @@ resolveAliasAtPos -> IdeState -> NormalizedFilePath -> Position + -> VFS.CodePointPosition -> [LHsDecl GhcPs] -> [LImportDecl GhcPs] -> ExceptT PluginError m (Maybe ImportAlias) -resolveAliasAtPos getNamesAtPosFn state nfp pos decls imports = - case findImportAliasDeclAtPos pos imports of +resolveAliasAtPos getNamesAtPosFn state nfp pos codePointPos decls imports = + case findImportAliasDeclAtPos codePointPos imports of Just result -> pure (Just result) - Nothing -> case findImportAliasUseAtPos pos decls imports of + Nothing -> case findImportAliasUseAtPos codePointPos decls imports of [] -> pure Nothing [result] -> pure (Just result) candidates -> do @@ -197,13 +203,18 @@ aliasBasedRename state nfp uri importAlias imports decls newNameText = do , Just locatedAlias <- [ideclAs importDecl] , unLoc locatedAlias == oldAlias ] > 1 + virtualFile <- runActionE "rename.getVirtualFile" state $ + handleMaybeM (PluginInternalError ("Virtual file not found: " <> T.show nfp)) $ + getVirtualFile nfp useSiteSpans <- if duplicateAlias then importAliasUseSiteSpansDisambiguated state nfp importAlias decls else pure $ importAliasUseSiteSpans importAlias decls - let declEdit = importAliasDeclEdit newNameText declSpan - useEdits = map (importAliasUseSiteEdit oldAlias newNameText) useSiteSpans - allEdits = declEdit : useEdits + declEdit <- handleMaybe (PluginInternalError "Alias declaration span is out of range") $ + importAliasDeclEdit virtualFile newNameText declSpan + useEdits <- handleMaybe (PluginInternalError "A use site span is out of range") $ + mapM (importAliasUseSiteEdit virtualFile oldAlias newNameText) useSiteSpans + let allEdits = declEdit : useEdits verTxtDocId <- liftIO $ runAction "rename.getVersionedTextDoc" state $ getVersionedTextDoc (TextDocumentIdentifier uri) let fileChanges = Just $ M.singleton (verTxtDocId ^. L.uri) allEdits @@ -228,33 +239,45 @@ importAliasUseSiteSpans importAlias decls = -- | Build a 'TextEdit' replacing the qualifier part in a qualified name (like -- from @Alias.name@ to @NewAlias.name@). --- (NOTE: GHC uses 1-based positioning; LSP uses 0-based.) +-- Returns 'Nothing' if the span is out of bounds in the VFS. importAliasUseSiteEdit - :: ModuleName -- ^ old alias, used to compute the qualifier width + :: VFS.VirtualFile + -> ModuleName -- ^ old alias, used to compute the qualifier width -> T.Text -- ^ new alias text - -> RealSrcSpan -- ^ span of the full qualified name, such as @Alias.name@ - -> TextEdit -importAliasUseSiteEdit oldAlias newAlias fullNameSpan = TextEdit range newAlias + -> RealSrcSpan -- ^ span of the full qualified name, e.g. @Alias.name@ + -> Maybe TextEdit +importAliasUseSiteEdit virtualFile oldAlias newAlias fullNameSpan = + codePointRangeToTextEdit virtualFile newAlias qualifierCodePointRange where - start = realSrcSpanStart fullNameSpan - line = fromIntegral (srcLocLine start) - 1 - startCol = fromIntegral (srcLocCol start) - 1 - endCol = startCol + fromIntegral (length (moduleNameString oldAlias)) - range = Range (Position line startCol) (Position line endCol) + spanStart = realSrcSpanStart fullNameSpan + line = fromIntegral (srcLocLine spanStart) - 1 + startColumn = fromIntegral (srcLocCol spanStart) - 1 + qualifierLength = fromIntegral (length (moduleNameString oldAlias)) + qualifierCodePointRange = VFS.CodePointRange + (VFS.CodePointPosition line startColumn) + (VFS.CodePointPosition line (startColumn + qualifierLength)) -- | Build a 'TextEdit' replacing the alias token in an import declaration (like -- from @import Module as Alias@ to @import Module as NewAlias@). +-- Returns 'Nothing' if the span is out of bounds in the VFS. importAliasDeclEdit - :: T.Text -- ^ new alias text + :: VFS.VirtualFile + -> T.Text -- ^ new alias text -> RealSrcSpan -- ^ span of @Alias@ in @import Module as Alias@ - -> TextEdit -importAliasDeclEdit newAlias rsp = TextEdit (realSrcSpanToRange rsp) newAlias + -> Maybe TextEdit +importAliasDeclEdit virtualFile newAlias rsp = + codePointRangeToTextEdit virtualFile newAlias (realSrcSpanToCodePointRange rsp) --- | Check whether a range contains a position (inclusive start, exclusive end). -rangeContainsPosition :: Range -> Position -> Bool -rangeContainsPosition (Range (Position sl sc) (Position el ec)) (Position l c) - = (l > sl || (l == sl && c >= sc)) - && (l < el || (l == el && c < ec)) +-- | Check whether a 'CodePointRange' contains a 'CodePointPosition' +-- (inclusive start, exclusive end). +codePointRangeContainsPosition :: VFS.CodePointRange -> VFS.CodePointPosition -> Bool +codePointRangeContainsPosition + (VFS.CodePointRange + (VFS.CodePointPosition startLine startColumn) + (VFS.CodePointPosition endLine endColumn)) + (VFS.CodePointPosition line column) + = (line > startLine || (line == startLine && column >= startColumn)) + && (line < endLine || (line == endLine && column < endColumn)) --------------------------------------------------------------------------------------------------- -- Internal helpers @@ -321,3 +344,24 @@ importAliasUseSiteSpansWithOcc oldAlias decls = , qualifier == oldAlias , RealSrcSpan rsp _ <- [getLocA locatedRdrName] ] + +--------------------------------------------------------------------------------------------------- +-- Util + +-- | Convert a 'RealSrcSpan' to a 'VFS.CodePointRange'. +-- GHC uses 1-based lines and columns; 'CodePointPosition' is 0-based. +realSrcSpanToCodePointRange :: RealSrcSpan -> VFS.CodePointRange +realSrcSpanToCodePointRange rsp = VFS.CodePointRange + (VFS.CodePointPosition + (fromIntegral (srcLocLine (realSrcSpanStart rsp)) - 1) + (fromIntegral (srcLocCol (realSrcSpanStart rsp)) - 1)) + (VFS.CodePointPosition + (fromIntegral (srcLocLine (realSrcSpanEnd rsp)) - 1) + (fromIntegral (srcLocCol (realSrcSpanEnd rsp)) - 1)) + +-- | Build a 'TextEdit' from a 'VFS.CodePointRange' and replacement text. +-- Returns 'Nothing' if the range is out of bounds in the VFS. +codePointRangeToTextEdit :: VFS.VirtualFile -> T.Text -> VFS.CodePointRange -> Maybe TextEdit +codePointRangeToTextEdit virtualFile newText codePointRange = + TextEdit <$> VFS.codePointRangeToRange virtualFile codePointRange + <*> Just newText diff --git a/plugins/hls-rename-plugin/test/testdata/ImportAlias.hs b/plugins/hls-rename-plugin/test/testdata/ImportAlias.hs index af4bb0c0c7..46241ed2a9 100644 --- a/plugins/hls-rename-plugin/test/testdata/ImportAlias.hs +++ b/plugins/hls-rename-plugin/test/testdata/ImportAlias.hs @@ -1,9 +1,9 @@ import Foo ((!)) -import Foo as F +import Foo as 𝐹𝔽 import Missing.Module as M bar :: Int -> Int -bar = F.foo +bar = 𝐹𝔽.foo baz :: Int -> Int -> Int baz = (!) From 6539bae391dfa232a50345dc4b6ee1023b86852e Mon Sep 17 00:00:00 2001 From: izuzu Date: Fri, 27 Mar 2026 03:25:48 +0800 Subject: [PATCH 23/31] Reorganize, clean up code; improve readability --- NOTES.md | 2 +- .../src/Ide/Plugin/Rename.hs | 15 +- .../src/Ide/Plugin/Rename/ImportAlias.hs | 474 ++++++++---------- 3 files changed, 217 insertions(+), 274 deletions(-) diff --git a/NOTES.md b/NOTES.md index 9f2b2b93f1..a87f19bdbf 100644 --- a/NOTES.md +++ b/NOTES.md @@ -125,7 +125,7 @@ Consider the `-- Before: --` example above. When the user places the cursor on e 2. `prepareRenameProvider` returns `PrepareRenameDefaultBehavior True` if the cursor is on a renameable alias. - TODO: In general, `PrepareRenameResult` feels underutilized. + - TODO: PR #4867 replaces `defaultBehavior` with explicit `Range` responses. If it is accepted, modify this PR. 3. Fail early if HLS can’t get the parsed module, instead of falling through. The existing renaming logic also needs the module to be parsed (and then typechecked) anyway. diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs index 12f15c46c7..b5fc7fd9b9 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs @@ -100,9 +100,9 @@ prepareRenameProvider state _pluginId (PrepareRenameParams (TextDocumentIdentifi Just parsed -> do let hsModule = unLoc $ pm_parsed_source parsed imports = hsmodImports hsModule - decls = hsmodDecls hsModule + hsDecls = hsmodDecls hsModule maybeAlias <- ImportAlias.resolveAliasAtPos - getNamesAtPos state nfp lspPos codePointPos decls imports + getNamesAtPos state nfp lspPos codePointPos imports hsDecls case maybeAlias of Just _ -> pure $ InL $ PrepareRenameResult $ InR $ InR $ PrepareRenameDefaultBehavior True Nothing -> do @@ -128,12 +128,12 @@ renameProvider state pluginId (RenameParams _prog (TextDocumentIdentifier uri) l Just parsed -> do let hsModule = unLoc $ pm_parsed_source parsed imports = hsmodImports hsModule - decls = hsmodDecls hsModule + hsDecls = hsmodDecls hsModule maybeAlias <- ImportAlias.resolveAliasAtPos - getNamesAtPos state nfp lspPos codePointPos decls imports + getNamesAtPos state nfp lspPos codePointPos imports hsDecls case maybeAlias of - Just importAlias -> - ImportAlias.aliasBasedRename state nfp uri importAlias imports decls newNameText + Just importAlias -> ImportAlias.aliasBasedRename + state nfp uri importAlias hsDecls newNameText Nothing -> nameBasedRename state pluginId nfp lspPos newNameText @@ -289,7 +289,8 @@ getCodePointPosition :: ExceptT PluginError m VFS.CodePointPosition getCodePointPosition state nfp pos = do virtualFile <- runActionE "rename.getVirtualFile" state - $ handleMaybeM (PluginInternalError ("Virtual file not found: " <> T.show nfp)) + $ handleMaybeM (PluginInternalError + ("Virtual file not found: " <> T.pack (show nfp))) $ getVirtualFile nfp case VFS.positionToCodePointPosition virtualFile pos of Nothing -> throwError $ PluginInvalidParams diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs index 88ddd9f546..1e0e7c6846 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs @@ -1,5 +1,6 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE OverloadedStrings #-} +{-# OPTIONS_GHC -Wall -Werror #-} {-| Logic for renaming qualified import aliases. @@ -32,188 +33,171 @@ spans are in Unicode code points. Instead, this module uses -} module Ide.Plugin.Rename.ImportAlias ( getParsedModuleStale - , ImportAlias (..) - , findImportAliasDeclAtPos - , findImportAliasUseAtPos , resolveAliasAtPos , aliasBasedRename - , importAliasUseSiteSpans - , importAliasUseSiteEdit - , importAliasDeclEdit - , codePointRangeContainsPosition ) where -import Control.Lens ((^.)) +import Control.Lens ((&), (+~), (.~), (^.)) +import Control.Monad (guard) import Control.Monad.Except (ExceptT, MonadError (throwError)) import Control.Monad.IO.Class (MonadIO, liftIO) +import Data.Containers.ListUtils (nubOrd) import Data.Generics import qualified Data.Map as M import Data.Maybe import qualified Data.Text as T +import Development.IDE (realSrcSpanToCodePointRange) import Development.IDE.Core.FileStore (getVersionedTextDoc) import Development.IDE.Core.PluginUtils 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.Types.Location +import Development.IDE.GHC.Compat hiding (importDecl) +import GHC.Data.FastString (lengthFS) import Ide.Plugin.Error import qualified Language.LSP.Protocol.Lens as L import Language.LSP.Protocol.Message -import Language.LSP.Protocol.Types +import Language.LSP.Protocol.Types hiding (Position, Range) +import qualified Language.LSP.Protocol.Types as LSP import qualified Language.LSP.VFS as VFS --- | The module name, alias name, and declaration span for an import alias. --- For example, @import Data.List as L@ corresponds to --- @ImportAlias "Data.List" "L" @. +-- | The module name, alias name, declaration text range, and sharing status +-- for an import alias. +-- For example, @import Data.List as L@ corresponds to @ImportAlias "Data.List" +-- "L" @. data ImportAlias = ImportAlias { aliasModuleName :: ModuleName , aliasName :: ModuleName - , aliasDeclSpan :: RealSrcSpan + , aliasDeclRange :: VFS.CodePointRange + , aliasIsShared :: Bool } + deriving (Eq, Ord) -- | Fetch the parsed module for a file, accepting a stale result. -- Returns @Nothing@ if the file has never been indexed. -getParsedModuleStale - :: MonadIO m - => IdeState - -> NormalizedFilePath - -> m (Maybe ParsedModule) +getParsedModuleStale :: + MonadIO m => + IdeState -> + NormalizedFilePath -> + m (Maybe ParsedModule) getParsedModuleStale state nfp = liftIO $ fmap fst <$> runAction "rename.getParsedModuleStale" state (useWithStale GetParsedModule nfp) --- | Find the 'ImportAlias' for the alias declaration at the cursor, such as --- @Alias@ in @import Module as Alias@. -findImportAliasDeclAtPos - :: VFS.CodePointPosition - -> [LImportDecl GhcPs] - -> Maybe ImportAlias -findImportAliasDeclAtPos codePointPos imports = listToMaybe - [ ImportAlias {aliasModuleName, aliasName, aliasDeclSpan} - | importDecl <- map unLoc imports - , Just locatedAlias <- [ideclAs importDecl] - , RealSrcSpan aliasDeclSpan _ <- [getLocA locatedAlias] - , codePointRangeContainsPosition (realSrcSpanToCodePointRange aliasDeclSpan) codePointPos - , let aliasName = unLoc locatedAlias - aliasModuleName = unLoc (ideclName importDecl) - ] +-- | Find the 'ImportAlias' if the cursor is on an import alias declaration, +-- such as @L@ in @import Data.List as L@. +findAliasDeclAtPos :: + VFS.CodePointPosition -> + [LImportDecl GhcPs] -> + Maybe ImportAlias +findAliasDeclAtPos pos imports = listToMaybe $ do + let allAliases = mapMaybe (fmap unLoc . ideclAs . unLoc) imports + importDecl <- map unLoc imports + Just locatedAlias <- [ideclAs importDecl] + RealSrcSpan aliasDeclSpan _ <- [getLoc locatedAlias] + let aliasDeclRange = realSrcSpanToCodePointRange aliasDeclSpan + guard (rangeContainsPosition aliasDeclRange pos) + let aliasModuleName = unLoc (ideclName importDecl) + aliasName = unLoc locatedAlias + aliasIsShared = length (filter (== aliasName) allAliases) > 1 + [ImportAlias{aliasModuleName, aliasName, aliasDeclRange, aliasIsShared}] -- | Find the 'ImportAlias' matching the name qualifier at the cursor, such as --- @Alias@ in @Alias.name@. +-- @L@ in @L.take@. -- Returns multiple values if multiple modules share the same alias. -findImportAliasUseAtPos - :: VFS.CodePointPosition - -> [LHsDecl GhcPs] - -> [LImportDecl GhcPs] - -> [ImportAlias] -findImportAliasUseAtPos codePointPos decls imports = - case listToMaybe - [ qualifier - | locatedRdrName :: XRec GhcPs RdrName <- listify (const True) decls - , Qual qualifier _ <- [unLoc locatedRdrName] - , RealSrcSpan useSiteSpan _ <- [getLocA locatedRdrName] - , codePointRangeContainsPosition (realSrcSpanToCodePointRange useSiteSpan) codePointPos - , let qualifierLength = fromIntegral (length (moduleNameString qualifier)) - spanStart = realSrcSpanStart useSiteSpan - line = fromIntegral (srcLocLine spanStart) - 1 - startColumn = fromIntegral (srcLocCol spanStart) - 1 - qualifierRange = VFS.CodePointRange - (VFS.CodePointPosition line startColumn) - (VFS.CodePointPosition line (startColumn + qualifierLength)) - , codePointRangeContainsPosition qualifierRange codePointPos - ] of - Nothing -> [] - Just qualifierAtPos -> - [ ImportAlias {aliasModuleName, aliasName, aliasDeclSpan} - | importDecl <- map unLoc imports - , Just locatedAlias <- [ideclAs importDecl] - , let aliasName = unLoc locatedAlias - , aliasName == qualifierAtPos - , RealSrcSpan aliasDeclSpan _ <- [getLocA locatedAlias] - , let aliasModuleName = unLoc (ideclName importDecl) - ] +findAliasUseAtPos :: + VFS.CodePointPosition -> + [LImportDecl GhcPs] -> + [LHsDecl GhcPs] -> + [ImportAlias] +findAliasUseAtPos pos imports hsDecls = + let qualifiersAtPos = do + locatedRdrName :: XRec GhcPs RdrName <- listify (const True) hsDecls + Qual qualifier _ <- [unLoc locatedRdrName] + RealSrcSpan qualifiedNameSpan _ <- [getLoc locatedRdrName] + let qualifiedNameRange = realSrcSpanToCodePointRange qualifiedNameSpan + guard (rangeContainsPosition qualifiedNameRange pos) + let qualifierLength = fromIntegral (moduleNameLength qualifier) + qualifierStart = qualifiedNameRange ^. VFS.start + qualifierRange = qualifiedNameRange + & VFS.end .~ (qualifierStart & VFS.character +~ qualifierLength) + guard (rangeContainsPosition qualifierRange pos) + [qualifier] + in case qualifiersAtPos of + [] -> [] + qualifierAtPos : _ -> do + let allAliases = mapMaybe (fmap unLoc . ideclAs . unLoc) imports + importDecl <- map unLoc imports + Just locatedAlias <- [ideclAs importDecl] + let aliasName = unLoc locatedAlias + guard (aliasName == qualifierAtPos) + RealSrcSpan aliasDeclSpan _ <- [getLoc locatedAlias] + let aliasModuleName = unLoc (ideclName importDecl) + aliasDeclRange = realSrcSpanToCodePointRange aliasDeclSpan + aliasIsShared = length (filter (== aliasName) allAliases) > 1 + [ImportAlias{aliasModuleName, aliasName, aliasDeclRange, aliasIsShared}] --- | Return the module name and declaration span for the alias being renamed at --- the cursor. The cursor may be on the alias token in an import declaration or --- on a qualifier at a use site. If multiple imports share the same alias, falls --- back to the typechecked module's 'GlobalRdrEnv' to disambiguate. --- Returns @Nothing@ if the cursor is not on any alias declaration or qualifier. +-- | Return the 'ImportAlias' being renamed at the cursor. The cursor may be on +-- the alias token in an import declaration or on a qualifier at a use site. If +-- multiple imports share the same alias, falls back to the typechecked module's +-- 'GlobalRdrEnv' to disambiguate. +-- Returns @Nothing@ if the cursor is not on an import alias. -- HACK: The first argument is `Rename.getNamesAtPos`, parameterized to avoid a -- circular dependency. -resolveAliasAtPos - :: MonadIO m - => (IdeState -> NormalizedFilePath -> Position -> ExceptT PluginError m [Name]) - -> IdeState - -> NormalizedFilePath - -> Position - -> VFS.CodePointPosition - -> [LHsDecl GhcPs] - -> [LImportDecl GhcPs] - -> ExceptT PluginError m (Maybe ImportAlias) -resolveAliasAtPos getNamesAtPosFn state nfp pos codePointPos decls imports = - case findImportAliasDeclAtPos codePointPos imports of - Just result -> pure (Just result) - Nothing -> case findImportAliasUseAtPos codePointPos decls imports of - [] -> pure Nothing - [result] -> pure (Just result) +resolveAliasAtPos :: + MonadIO m => + (IdeState -> NormalizedFilePath -> LSP.Position -> ExceptT PluginError m [Name]) -> + IdeState -> + NormalizedFilePath -> + LSP.Position -> + VFS.CodePointPosition -> + [LImportDecl GhcPs] -> + [LHsDecl GhcPs] -> + ExceptT PluginError m (Maybe ImportAlias) +resolveAliasAtPos getNamesAtPosFn state nfp lspPos pos imports hsDecls = + case findAliasDeclAtPos pos imports of + Just alias -> pure (Just alias) + Nothing -> case findAliasUseAtPos pos imports hsDecls of + [] -> pure Nothing + [alias] -> pure (Just alias) candidates -> do - namesAtPos <- getNamesAtPosFn state nfp pos - disambiguated <- disambiguateAliasUse state nfp namesAtPos candidates - case disambiguated of + tcModule <- runActionE "rename.resolveAlias" state $ useE TypeCheck nfp + namesAtPos <- getNamesAtPosFn state nfp lspPos + case disambiguateAliasUse tcModule namesAtPos candidates of [] -> pure Nothing - [result] -> pure (Just result) + [alias] -> pure (Just alias) aliases -> throwError $ PluginInvalidParams $ ambiguousAliasErrorMessage aliases - where - ambiguousAliasErrorMessage [] = "" - ambiguousAliasErrorMessage [_] = "" - ambiguousAliasErrorMessage aliases@(alias1 : alias2 : _) = - let aliasCount = T.show (length aliases) - aliasText = T.pack (moduleNameString (aliasName alias1)) - module1 = T.pack (moduleNameString (aliasModuleName alias1)) - module2 = T.pack (moduleNameString (aliasModuleName alias2)) - quote t = "‘" <> t <> "’" - in ("Alias " <> quote aliasText - <> " is ambiguous (matching " <> aliasCount - <> " imports, including " - <> quote module1 <> " and " <> quote module2 - <> "). Try renaming " <> quote aliasText - <> " in one of these import declarations directly.") -- | Build a 'WorkspaceEdit' renaming an import alias and all its use sites. -aliasBasedRename - :: MonadIO m - => IdeState - -> NormalizedFilePath - -> Uri - -> ImportAlias - -> [LImportDecl GhcPs] - -> [LHsDecl GhcPs] - -> T.Text - -> ExceptT PluginError m (MessageResult Method_TextDocumentRename) -aliasBasedRename state nfp uri importAlias imports decls newNameText = do - let oldAlias = aliasName importAlias - declSpan = aliasDeclSpan importAlias - duplicateAlias = - length [ () - | importDecl <- map unLoc imports - , Just locatedAlias <- [ideclAs importDecl] - , unLoc locatedAlias == oldAlias - ] > 1 - virtualFile <- runActionE "rename.getVirtualFile" state $ - handleMaybeM (PluginInternalError ("Virtual file not found: " <> T.show nfp)) $ - getVirtualFile nfp - useSiteSpans <- - if duplicateAlias - then importAliasUseSiteSpansDisambiguated state nfp importAlias decls - else pure $ importAliasUseSiteSpans importAlias decls - declEdit <- handleMaybe (PluginInternalError "Alias declaration span is out of range") $ - importAliasDeclEdit virtualFile newNameText declSpan - useEdits <- handleMaybe (PluginInternalError "A use site span is out of range") $ - mapM (importAliasUseSiteEdit virtualFile oldAlias newNameText) useSiteSpans +aliasBasedRename :: + MonadIO m => + IdeState -> + NormalizedFilePath -> + Uri -> + ImportAlias -> + [LHsDecl GhcPs] -> + T.Text -> + ExceptT PluginError m (MessageResult Method_TextDocumentRename) +aliasBasedRename state nfp uri importAlias hsDecls newNameText = do + let ImportAlias{aliasDeclRange, aliasIsShared} = importAlias + virtualFile <- runActionE "rename.getVirtualFile" state + $ handleMaybeM (PluginInternalError + ("Virtual file not found: " <> T.pack (show nfp))) + $ getVirtualFile nfp + useSiteRanges <- + if aliasIsShared + then do + tcModule <- runActionE "rename.sharedAliasRanges" state $ useE TypeCheck nfp + pure $ aliasUseSiteRangesDisambiguated tcModule importAlias hsDecls + else + pure $ aliasUseSiteRanges importAlias hsDecls + declEdit <- handleMaybe (PluginInternalError "Alias declaration span is out of range") + $ rangeToTextEdit virtualFile newNameText aliasDeclRange + useEdits <- handleMaybe (PluginInternalError "A use site span is out of range") + $ traverse (rangeToTextEdit virtualFile newNameText) useSiteRanges let allEdits = declEdit : useEdits verTxtDocId <- liftIO $ runAction "rename.getVersionedTextDoc" state $ getVersionedTextDoc (TextDocumentIdentifier uri) @@ -222,146 +206,104 @@ aliasBasedRename state nfp uri importAlias imports decls newNameText = do workspaceEdit = WorkspaceEdit fileChanges Nothing Nothing pure $ InL workspaceEdit --- | Collect the 'RealSrcSpan' of every qualified use of @oldAlias@, such as in --- @oldAlias.foo@, @oldAlias.bar@, and so on. --- Does not disambiguate if multiple imports share the alias. -importAliasUseSiteSpans - :: ImportAlias - -> [LHsDecl GhcPs] - -> [RealSrcSpan] -importAliasUseSiteSpans importAlias decls = - [ fullNameSpan - | locatedRdrName :: XRec GhcPs RdrName <- listify (const True) decls - , Qual qualifier _ <- [unLoc locatedRdrName] - , qualifier == aliasName importAlias - , RealSrcSpan fullNameSpan _ <- [getLocA locatedRdrName] - ] - --- | Build a 'TextEdit' replacing the qualifier part in a qualified name (like --- from @Alias.name@ to @NewAlias.name@). --- Returns 'Nothing' if the span is out of bounds in the VFS. -importAliasUseSiteEdit - :: VFS.VirtualFile - -> ModuleName -- ^ old alias, used to compute the qualifier width - -> T.Text -- ^ new alias text - -> RealSrcSpan -- ^ span of the full qualified name, e.g. @Alias.name@ - -> Maybe TextEdit -importAliasUseSiteEdit virtualFile oldAlias newAlias fullNameSpan = - codePointRangeToTextEdit virtualFile newAlias qualifierCodePointRange - where - spanStart = realSrcSpanStart fullNameSpan - line = fromIntegral (srcLocLine spanStart) - 1 - startColumn = fromIntegral (srcLocCol spanStart) - 1 - qualifierLength = fromIntegral (length (moduleNameString oldAlias)) - qualifierCodePointRange = VFS.CodePointRange - (VFS.CodePointPosition line startColumn) - (VFS.CodePointPosition line (startColumn + qualifierLength)) - --- | Build a 'TextEdit' replacing the alias token in an import declaration (like --- from @import Module as Alias@ to @import Module as NewAlias@). --- Returns 'Nothing' if the span is out of bounds in the VFS. -importAliasDeclEdit - :: VFS.VirtualFile - -> T.Text -- ^ new alias text - -> RealSrcSpan -- ^ span of @Alias@ in @import Module as Alias@ - -> Maybe TextEdit -importAliasDeclEdit virtualFile newAlias rsp = - codePointRangeToTextEdit virtualFile newAlias (realSrcSpanToCodePointRange rsp) - --- | Check whether a 'CodePointRange' contains a 'CodePointPosition' --- (inclusive start, exclusive end). -codePointRangeContainsPosition :: VFS.CodePointRange -> VFS.CodePointPosition -> Bool -codePointRangeContainsPosition - (VFS.CodePointRange - (VFS.CodePointPosition startLine startColumn) - (VFS.CodePointPosition endLine endColumn)) - (VFS.CodePointPosition line column) - = (line > startLine || (line == startLine && column >= startColumn)) - && (line < endLine || (line == endLine && column < endColumn)) +-- | Collect the 'CodePointRange' of every qualified use of @importAlias@, such +-- as @L@ in @L.take@, @L.drop@, and so on. +aliasUseSiteRanges :: ImportAlias -> [LHsDecl GhcPs] -> [VFS.CodePointRange] +aliasUseSiteRanges importAlias hsDecls = nubOrd $ do + let ImportAlias{aliasName} = importAlias + aliasLength = fromIntegral (moduleNameLength aliasName) + locatedRdrName :: XRec GhcPs RdrName <- listify (const True) hsDecls + Qual qualifier _ <- [unLoc locatedRdrName] + guard (qualifier == aliasName) + RealSrcSpan qualifiedNameSpan _ <- [getLoc locatedRdrName] + let qualifiedNameRange = realSrcSpanToCodePointRange qualifiedNameSpan + qualifierStart = qualifiedNameRange ^. VFS.start + qualifierRange = qualifiedNameRange + & VFS.end .~ (qualifierStart & VFS.character +~ aliasLength) + [qualifierRange] --------------------------------------------------------------------------------------------------- --- Internal helpers +-- Special case: Multiple imports use the same alias --- | Resolve an ambiguous alias use site by consulting the typechecked --- module's 'GlobalRdrEnv'. Used when multiple imports share the same alias. --- The caller is responsible for providing the names at the cursor. --- Returns multiple results if they both export the same name (such as @L.view@ --- with both @Control.Lens as L@ and @Control.Lens.Getter as L@). -disambiguateAliasUse - :: MonadIO m - => IdeState - -> NormalizedFilePath - -> [Name] - -> [ImportAlias] - -> ExceptT PluginError m [ImportAlias] -disambiguateAliasUse state nfp namesAtPos candidates = do - tcModule <- runActionE "rename.disambiguateAlias" state (useE TypeCheck nfp) +-- | Resolve an ambiguous name qualifier by consulting the typechecked module's +-- 'GlobalRdrEnv' (or GRE). Used when multiple imports share the same alias. The +-- caller is responsible for providing the names at the cursor. +-- Returns multiple results if multiple modules export the same name (such as +-- @L.view@ with both @Control.Lens as L@ and @Control.Lens.Getter as L@). +disambiguateAliasUse :: + TcModuleResult -> + [Name] -> + [ImportAlias] -> + [ImportAlias] +disambiguateAliasUse tcModule namesAtPos candidates = nubOrd $ do let rdrEnv = tcg_rdr_env (tmrTypechecked tcModule) - pure - [ candidate - | name <- namesAtPos - , globalRdrEnvElement <- maybeToList (lookupGRE_Name rdrEnv name) - , importSpec <- gre_imp globalRdrEnvElement - , candidate@ImportAlias{aliasModuleName} <- candidates - , importSpecModule importSpec == aliasModuleName - ] + name <- namesAtPos + nameGREElement <- maybeToList (lookupGRE_Name rdrEnv name) + importSpec <- gre_imp nameGREElement + candidate@ImportAlias{aliasModuleName} <- candidates + guard (importSpecModule importSpec == aliasModuleName) + [candidate] --- | Like 'importAliasUseSiteSpans' but filters to use sites that resolve --- to names from @actualMod@, using the typechecked module's 'GlobalRdrEnv'. +-- | A variant of 'aliasUseSiteRanges' that resolves name qualifiers into full +-- module names and only selects those matching the module of @importAlias@. -- Used when multiple imports share the same alias. -importAliasUseSiteSpansDisambiguated - :: MonadIO m - => IdeState - -> NormalizedFilePath - -> ImportAlias - -> [LHsDecl GhcPs] - -> ExceptT PluginError m [RealSrcSpan] -importAliasUseSiteSpansDisambiguated state nfp importAlias decls = do - tcModule <- runActionE "rename.useSiteSpans" state (useE TypeCheck nfp) +aliasUseSiteRangesDisambiguated :: + TcModuleResult -> + ImportAlias -> + [LHsDecl GhcPs] -> + [VFS.CodePointRange] +aliasUseSiteRangesDisambiguated tcModule importAlias hsDecls = nubOrd $ do let rdrEnv = tcg_rdr_env (tmrTypechecked tcModule) ImportAlias{aliasModuleName, aliasName} = importAlias - allSpans = importAliasUseSiteSpansWithOcc aliasName decls - maybeMatchingSpan (occName, rsp) = - let rdrName = Qual aliasName occName - in listToMaybe - [ rsp - | gre <- pickGREs rdrName $ lookupGlobalRdrEnv rdrEnv occName - , impSpec <- gre_imp gre - , importSpecModule impSpec == aliasModuleName - ] - pure $ mapMaybe maybeMatchingSpan allSpans + aliasLength = fromIntegral (moduleNameLength aliasName) + locatedRdrName :: XRec GhcPs RdrName <- listify (const True) hsDecls + rdrName@(Qual qualifier name) <- [unLoc locatedRdrName] + guard (qualifier == aliasName) + nameGREElement <- pickGREs rdrName $ lookupGlobalRdrEnv rdrEnv name + importSpec <- gre_imp nameGREElement + guard (importSpecModule importSpec == aliasModuleName) + RealSrcSpan qualifiedNameSpan _ <- [getLoc locatedRdrName] + let qualifiedNameRange = realSrcSpanToCodePointRange qualifiedNameSpan + qualifierStart = qualifiedNameRange ^. VFS.start + qualifierRange = qualifiedNameRange + & VFS.end .~ (qualifierStart & VFS.character +~ aliasLength) + [qualifierRange] --- | Like 'importAliasUseSiteSpans' but also returns the 'OccName' of each --- use, needed for 'GlobalRdrEnv' lookup in the disambiguated path. -importAliasUseSiteSpansWithOcc - :: ModuleName - -> [LHsDecl GhcPs] - -> [(OccName, RealSrcSpan)] -importAliasUseSiteSpansWithOcc oldAlias decls = - [ (occName, rsp) - | locatedRdrName :: XRec GhcPs RdrName <- listify (const True) decls - , Qual qualifier occName <- [unLoc locatedRdrName] - , qualifier == oldAlias - , RealSrcSpan rsp _ <- [getLocA locatedRdrName] - ] +ambiguousAliasErrorMessage :: [ImportAlias] -> T.Text +ambiguousAliasErrorMessage aliases@(alias1 : alias2 : _) = + let aliasCount = T.pack (show (length aliases)) + aliasText = T.pack (moduleNameString (aliasName alias1)) + module1 = T.pack (moduleNameString (aliasModuleName alias1)) + module2 = T.pack (moduleNameString (aliasModuleName alias2)) + quote t = "‘" <> t <> "’" + in ("Alias " <> quote aliasText + <> " is ambiguous (matching " <> aliasCount + <> " imports, including " <> quote module1 <> " and " <> quote module2 + <> "). Try renaming " <> quote aliasText + <> " in one of these import declarations directly.") +ambiguousAliasErrorMessage _ = "" --------------------------------------------------------------------------------------------------- --- Util +-- Utility functions --- | Convert a 'RealSrcSpan' to a 'VFS.CodePointRange'. --- GHC uses 1-based lines and columns; 'CodePointPosition' is 0-based. -realSrcSpanToCodePointRange :: RealSrcSpan -> VFS.CodePointRange -realSrcSpanToCodePointRange rsp = VFS.CodePointRange - (VFS.CodePointPosition - (fromIntegral (srcLocLine (realSrcSpanStart rsp)) - 1) - (fromIntegral (srcLocCol (realSrcSpanStart rsp)) - 1)) - (VFS.CodePointPosition - (fromIntegral (srcLocLine (realSrcSpanEnd rsp)) - 1) - (fromIntegral (srcLocCol (realSrcSpanEnd rsp)) - 1)) +-- | Check whether a 'CodePointRange' contains a 'CodePointPosition' +-- (inclusive start, exclusive end). +rangeContainsPosition :: VFS.CodePointRange -> VFS.CodePointPosition -> Bool +rangeContainsPosition + (VFS.CodePointRange + (VFS.CodePointPosition startLine startColumn) + (VFS.CodePointPosition endLine endColumn)) + (VFS.CodePointPosition posLine posColumn) + = (posLine > startLine || (posLine == startLine && posColumn >= startColumn)) + && (posLine < endLine || (posLine == endLine && posColumn < endColumn)) -- | Build a 'TextEdit' from a 'VFS.CodePointRange' and replacement text. --- Returns 'Nothing' if the range is out of bounds in the VFS. -codePointRangeToTextEdit :: VFS.VirtualFile -> T.Text -> VFS.CodePointRange -> Maybe TextEdit -codePointRangeToTextEdit virtualFile newText codePointRange = - TextEdit <$> VFS.codePointRangeToRange virtualFile codePointRange - <*> Just newText +-- Returns @Nothing@ if the range is out of bounds in the VFS. +rangeToTextEdit :: VFS.VirtualFile -> T.Text -> VFS.CodePointRange -> Maybe TextEdit +rangeToTextEdit virtualFile newText range = TextEdit + <$> VFS.codePointRangeToRange virtualFile range + <*> Just newText + +-- | Returns the length in Unicode code points for a 'ModuleName'. +moduleNameLength :: ModuleName -> Int +moduleNameLength = lengthFS . moduleNameFS From f23419f6d6e20d119111fc03946d99b87f82750d Mon Sep 17 00:00:00 2001 From: izuzu Date: Fri, 27 Mar 2026 03:27:26 +0800 Subject: [PATCH 24/31] Move exported functions to top --- .../src/Ide/Plugin/Rename/ImportAlias.hs | 106 +++++++++--------- 1 file changed, 53 insertions(+), 53 deletions(-) diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs index 1e0e7c6846..c316c62fee 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs @@ -86,59 +86,6 @@ getParsedModuleStale state nfp = runAction "rename.getParsedModuleStale" state (useWithStale GetParsedModule nfp) --- | Find the 'ImportAlias' if the cursor is on an import alias declaration, --- such as @L@ in @import Data.List as L@. -findAliasDeclAtPos :: - VFS.CodePointPosition -> - [LImportDecl GhcPs] -> - Maybe ImportAlias -findAliasDeclAtPos pos imports = listToMaybe $ do - let allAliases = mapMaybe (fmap unLoc . ideclAs . unLoc) imports - importDecl <- map unLoc imports - Just locatedAlias <- [ideclAs importDecl] - RealSrcSpan aliasDeclSpan _ <- [getLoc locatedAlias] - let aliasDeclRange = realSrcSpanToCodePointRange aliasDeclSpan - guard (rangeContainsPosition aliasDeclRange pos) - let aliasModuleName = unLoc (ideclName importDecl) - aliasName = unLoc locatedAlias - aliasIsShared = length (filter (== aliasName) allAliases) > 1 - [ImportAlias{aliasModuleName, aliasName, aliasDeclRange, aliasIsShared}] - --- | Find the 'ImportAlias' matching the name qualifier at the cursor, such as --- @L@ in @L.take@. --- Returns multiple values if multiple modules share the same alias. -findAliasUseAtPos :: - VFS.CodePointPosition -> - [LImportDecl GhcPs] -> - [LHsDecl GhcPs] -> - [ImportAlias] -findAliasUseAtPos pos imports hsDecls = - let qualifiersAtPos = do - locatedRdrName :: XRec GhcPs RdrName <- listify (const True) hsDecls - Qual qualifier _ <- [unLoc locatedRdrName] - RealSrcSpan qualifiedNameSpan _ <- [getLoc locatedRdrName] - let qualifiedNameRange = realSrcSpanToCodePointRange qualifiedNameSpan - guard (rangeContainsPosition qualifiedNameRange pos) - let qualifierLength = fromIntegral (moduleNameLength qualifier) - qualifierStart = qualifiedNameRange ^. VFS.start - qualifierRange = qualifiedNameRange - & VFS.end .~ (qualifierStart & VFS.character +~ qualifierLength) - guard (rangeContainsPosition qualifierRange pos) - [qualifier] - in case qualifiersAtPos of - [] -> [] - qualifierAtPos : _ -> do - let allAliases = mapMaybe (fmap unLoc . ideclAs . unLoc) imports - importDecl <- map unLoc imports - Just locatedAlias <- [ideclAs importDecl] - let aliasName = unLoc locatedAlias - guard (aliasName == qualifierAtPos) - RealSrcSpan aliasDeclSpan _ <- [getLoc locatedAlias] - let aliasModuleName = unLoc (ideclName importDecl) - aliasDeclRange = realSrcSpanToCodePointRange aliasDeclSpan - aliasIsShared = length (filter (== aliasName) allAliases) > 1 - [ImportAlias{aliasModuleName, aliasName, aliasDeclRange, aliasIsShared}] - -- | Return the 'ImportAlias' being renamed at the cursor. The cursor may be on -- the alias token in an import declaration or on a qualifier at a use site. If -- multiple imports share the same alias, falls back to the typechecked module's @@ -206,6 +153,59 @@ aliasBasedRename state nfp uri importAlias hsDecls newNameText = do workspaceEdit = WorkspaceEdit fileChanges Nothing Nothing pure $ InL workspaceEdit +-- | Find the 'ImportAlias' if the cursor is on an import alias declaration, +-- such as @L@ in @import Data.List as L@. +findAliasDeclAtPos :: + VFS.CodePointPosition -> + [LImportDecl GhcPs] -> + Maybe ImportAlias +findAliasDeclAtPos pos imports = listToMaybe $ do + let allAliases = mapMaybe (fmap unLoc . ideclAs . unLoc) imports + importDecl <- map unLoc imports + Just locatedAlias <- [ideclAs importDecl] + RealSrcSpan aliasDeclSpan _ <- [getLoc locatedAlias] + let aliasDeclRange = realSrcSpanToCodePointRange aliasDeclSpan + guard (rangeContainsPosition aliasDeclRange pos) + let aliasModuleName = unLoc (ideclName importDecl) + aliasName = unLoc locatedAlias + aliasIsShared = length (filter (== aliasName) allAliases) > 1 + [ImportAlias{aliasModuleName, aliasName, aliasDeclRange, aliasIsShared}] + +-- | Find the 'ImportAlias' matching the name qualifier at the cursor, such as +-- @L@ in @L.take@. +-- Returns multiple values if multiple modules share the same alias. +findAliasUseAtPos :: + VFS.CodePointPosition -> + [LImportDecl GhcPs] -> + [LHsDecl GhcPs] -> + [ImportAlias] +findAliasUseAtPos pos imports hsDecls = + let qualifiersAtPos = do + locatedRdrName :: XRec GhcPs RdrName <- listify (const True) hsDecls + Qual qualifier _ <- [unLoc locatedRdrName] + RealSrcSpan qualifiedNameSpan _ <- [getLoc locatedRdrName] + let qualifiedNameRange = realSrcSpanToCodePointRange qualifiedNameSpan + guard (rangeContainsPosition qualifiedNameRange pos) + let qualifierLength = fromIntegral (moduleNameLength qualifier) + qualifierStart = qualifiedNameRange ^. VFS.start + qualifierRange = qualifiedNameRange + & VFS.end .~ (qualifierStart & VFS.character +~ qualifierLength) + guard (rangeContainsPosition qualifierRange pos) + [qualifier] + in case qualifiersAtPos of + [] -> [] + qualifierAtPos : _ -> do + let allAliases = mapMaybe (fmap unLoc . ideclAs . unLoc) imports + importDecl <- map unLoc imports + Just locatedAlias <- [ideclAs importDecl] + let aliasName = unLoc locatedAlias + guard (aliasName == qualifierAtPos) + RealSrcSpan aliasDeclSpan _ <- [getLoc locatedAlias] + let aliasModuleName = unLoc (ideclName importDecl) + aliasDeclRange = realSrcSpanToCodePointRange aliasDeclSpan + aliasIsShared = length (filter (== aliasName) allAliases) > 1 + [ImportAlias{aliasModuleName, aliasName, aliasDeclRange, aliasIsShared}] + -- | Collect the 'CodePointRange' of every qualified use of @importAlias@, such -- as @L@ in @L.take@, @L.drop@, and so on. aliasUseSiteRanges :: ImportAlias -> [LHsDecl GhcPs] -> [VFS.CodePointRange] From 843d7aca3c9adb3a9804e56f8cb022bd8cf113a9 Mon Sep 17 00:00:00 2001 From: izuzu Date: Fri, 27 Mar 2026 04:52:55 +0800 Subject: [PATCH 25/31] Return alias range at cursor for `prepareRename` --- .../src/Ide/Plugin/Rename.hs | 5 ++- .../src/Ide/Plugin/Rename/ImportAlias.hs | 45 +++++++++++-------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs index b5fc7fd9b9..7eafe98867 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs @@ -104,7 +104,8 @@ prepareRenameProvider state _pluginId (PrepareRenameParams (TextDocumentIdentifi maybeAlias <- ImportAlias.resolveAliasAtPos getNamesAtPos state nfp lspPos codePointPos imports hsDecls case maybeAlias of - Just _ -> pure $ InL $ PrepareRenameResult $ InR $ InR $ PrepareRenameDefaultBehavior True + Just (lspRange, _importAlias) -> + pure $ InL $ PrepareRenameResult $ InL $ lspRange Nothing -> do -- 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. @@ -132,7 +133,7 @@ renameProvider state pluginId (RenameParams _prog (TextDocumentIdentifier uri) l maybeAlias <- ImportAlias.resolveAliasAtPos getNamesAtPos state nfp lspPos codePointPos imports hsDecls case maybeAlias of - Just importAlias -> ImportAlias.aliasBasedRename + Just (_lspRange, importAlias) -> ImportAlias.aliasBasedRename state nfp uri importAlias hsDecls newNameText Nothing -> nameBasedRename state pluginId nfp lspPos newNameText diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs index c316c62fee..e27c4fbe1b 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs @@ -60,6 +60,7 @@ import qualified Language.LSP.Protocol.Lens as L import Language.LSP.Protocol.Message import Language.LSP.Protocol.Types hiding (Position, Range) import qualified Language.LSP.Protocol.Types as LSP +import Language.LSP.VFS (codePointRangeToRange) import qualified Language.LSP.VFS as VFS -- | The module name, alias name, declaration text range, and sharing status @@ -86,10 +87,10 @@ getParsedModuleStale state nfp = runAction "rename.getParsedModuleStale" state (useWithStale GetParsedModule nfp) --- | Return the 'ImportAlias' being renamed at the cursor. The cursor may be on --- the alias token in an import declaration or on a qualifier at a use site. If --- multiple imports share the same alias, falls back to the typechecked module's --- 'GlobalRdrEnv' to disambiguate. +-- | Return the 'ImportAlias' and corresponding text range at the cursor. The +-- cursor may be on the alias token in an import declaration or on a qualifier +-- at a use site. If multiple imports share the same alias, falls back to the +-- typechecked module's 'GlobalRdrEnv' to disambiguate. -- Returns @Nothing@ if the cursor is not on an import alias. -- HACK: The first argument is `Rename.getNamesAtPos`, parameterized to avoid a -- circular dependency. @@ -102,19 +103,27 @@ resolveAliasAtPos :: VFS.CodePointPosition -> [LImportDecl GhcPs] -> [LHsDecl GhcPs] -> - ExceptT PluginError m (Maybe ImportAlias) -resolveAliasAtPos getNamesAtPosFn state nfp lspPos pos imports hsDecls = + ExceptT PluginError m (Maybe (LSP.Range, ImportAlias)) +resolveAliasAtPos getNamesAtPosFn state nfp lspPos pos imports hsDecls = do + virtualFile <- runActionE "rename.getVirtualFile" state + $ handleMaybeM (PluginInternalError + ("Virtual file not found: " <> T.pack (show nfp))) + $ getVirtualFile nfp + let toLSPRange (range, alias) = case codePointRangeToRange virtualFile range of + Nothing -> Nothing + Just lspRange -> Just (lspRange, alias) case findAliasDeclAtPos pos imports of - Just alias -> pure (Just alias) + Just alias -> pure $ toLSPRange (aliasDeclRange alias, alias) Nothing -> case findAliasUseAtPos pos imports hsDecls of - [] -> pure Nothing - [alias] -> pure (Just alias) - candidates -> do + Nothing -> pure Nothing + Just (_, []) -> pure Nothing + Just (range, [alias]) -> pure $ toLSPRange (range, alias) + Just (range, candidates) -> do tcModule <- runActionE "rename.resolveAlias" state $ useE TypeCheck nfp namesAtPos <- getNamesAtPosFn state nfp lspPos case disambiguateAliasUse tcModule namesAtPos candidates of [] -> pure Nothing - [alias] -> pure (Just alias) + [alias] -> pure $ toLSPRange (range, alias) aliases -> throwError $ PluginInvalidParams $ ambiguousAliasErrorMessage aliases @@ -171,14 +180,14 @@ findAliasDeclAtPos pos imports = listToMaybe $ do aliasIsShared = length (filter (== aliasName) allAliases) > 1 [ImportAlias{aliasModuleName, aliasName, aliasDeclRange, aliasIsShared}] --- | Find the 'ImportAlias' matching the name qualifier at the cursor, such as --- @L@ in @L.take@. --- Returns multiple values if multiple modules share the same alias. +-- | Find the text range and matching 'ImportAlias' for the name qualifier at +-- the cursor, such as @L@ in @L.take@. +-- Returns multiple aliases if multiple modules share the same alias. findAliasUseAtPos :: VFS.CodePointPosition -> [LImportDecl GhcPs] -> [LHsDecl GhcPs] -> - [ImportAlias] + Maybe (VFS.CodePointRange, [ImportAlias]) findAliasUseAtPos pos imports hsDecls = let qualifiersAtPos = do locatedRdrName :: XRec GhcPs RdrName <- listify (const True) hsDecls @@ -191,10 +200,10 @@ findAliasUseAtPos pos imports hsDecls = qualifierRange = qualifiedNameRange & VFS.end .~ (qualifierStart & VFS.character +~ qualifierLength) guard (rangeContainsPosition qualifierRange pos) - [qualifier] + [(qualifierRange, qualifier)] in case qualifiersAtPos of - [] -> [] - qualifierAtPos : _ -> do + [] -> Nothing + (rangeAtPos, qualifierAtPos) : _ -> Just $ (,) rangeAtPos $ do let allAliases = mapMaybe (fmap unLoc . ideclAs . unLoc) imports importDecl <- map unLoc imports Just locatedAlias <- [ideclAs importDecl] From cf4b9359baae44b88d84ab18eeb9bf618b557bb3 Mon Sep 17 00:00:00 2001 From: izuzu Date: Sat, 28 Mar 2026 01:03:23 +0800 Subject: [PATCH 26/31] Remove `NOTES.md` --- NOTES.md | 136 ------------------------------------------------------- 1 file changed, 136 deletions(-) delete mode 100644 NOTES.md diff --git a/NOTES.md b/NOTES.md deleted file mode 100644 index a87f19bdbf..0000000000 --- a/NOTES.md +++ /dev/null @@ -1,136 +0,0 @@ -# Notes - -This branch is for experimenting and attempting to implement renaming import aliases. For example: - -```haskell --- Before: ---------------------------- - -import qualified Data.List as L - -bar = L.take - --- After: ----------------------------- - -import qualified Data.List as List - -bar = List.take -``` - -## AI disclosure - -The author uses generative AI (specifically, Claude Sonnet 4.6) to understand key concepts and draft code. - -*The author has reviewed and understood every line of AI-generated content in this branch, personally vouches for it, and can explain any part of it if needed.* - -All notes generated through Claude are marked as such. - -## How renaming currently works - -`hls-rename-plugin` currently doesn’t handle import aliases. - -Consider the `-- Before: --` example above. When the user places the cursor on either occurrence of `L`, `hls-rename-plugin` consults the HIE AST and finds the identifier at the cursor. - -- In `import qualified Data.List as L`, `L` is a `ModuleName` identifier. - - The plugin currently doesn’t consider `ModuleName` identifiers renameable, so it responds with “No symbol to rename at given location.” - -- In `L.take`, the entirety of `L.take` is a single `Name` identifier. - - The AST records the name as external, coming from the `Data.List` module (not `L`, because the import alias is resolved). Information about the alias `L` is therefore lost. - - Also, a `Name` records the module in which the identifier is *defined*, not the module that the identifier is imported from. This can cause problems, especially for modules that re-export other modules. - -## Initial approach - -1. When the user hovers over `L.take`, use the HIE AST to get the full span of the identifier. -2. Use [`GHC.Parser`](https://hackage-content.haskell.org/package/ghc-9.12.1/docs/GHC-Parser.html) to parse the identifier into a `RdrName` of the form `Qual ModuleName OccName` and obtain the module alias as listed in the import section. -3. Use the AST to find all other external identifiers (and check using the `isExternalName` predicate). -4. Find their spans and parse these identifiers into `RdrName` values to find those with the same qualified module alias. -5. Replace the module alias in both the import statement and the identifiers. - -## Revised approach - -> 1. Get the parsed AST (`HsModule GhcPs`) via `GetParsedModule`. -> 2. Determine the alias being renamed by checking two cursor positions, in order: -> - **2a.** The cursor is on the alias token in an import declaration — traverse `hsmodImports` to find the `ImportDecl` whose `ideclAs` span contains the cursor. -> - **2b.** The cursor is on a qualifier at a use site — traverse `hsmodDecls` to find a `Qual moduleAlias _` `RdrName` whose qualifier span contains the cursor, then look up the matching `ideclAs` in `hsmodImports`. -> -> If multiple imports share the same alias, fall back to the renamed AST via a fresh `TypeCheck` to disambiguate. -> -> Both yield `(ModuleName, RealSrcSpan)`: the alias name and its span in the import declaration. -> 3. Traverse all `LocatedN RdrName` nodes in `hsmodDecls` via SYB `listify`, collect those with `Qual alias _` matching the target alias — extract their `RealSrcSpan`s from the annotation. -> 4. Replace the alias text in the `ideclAs` span in the import, and each collected use-site span. -> 5. Produce a `WorkspaceEdit` with all replacements. -> -> —Claude - -1. Using the parsed AST instead of the full HIE AST allows us to inspect `RdrName` identifiers, which contain unresolved import module aliases. It also turns out that this is already implemented as a rule in HLS. - -2. The cursor can be on either an import alias declaration (such as `L` in `import Data.List as L`) or a use site (such as `L` in `L.take`). - - - Common case (fast): If only one module is imported as `L`, then all renaming is done through the parsed AST, which is fast. - - - Special case (slow, but rare): If multiple modules are imported as `L`, then `hls-rename-plugin` consults the typechecked AST and `GlobalRdrEnv` to find which imported module is referred to. - - For example, targeting `L` in `L.take` in this example leaves the `Control.Lens` import unchanged: - - ```haskell - import Control.Lens as L -- > import Control.Lens as L - import Data.List as L -- > import Data.List as List - -- > - f = L.take -- > f = List.take - ``` - - Relevant link: [GHC wiki page on the renamer](https://gitlab.haskell.org/ghc/ghc/-/wikis/commentary/compiler/renamer?version_id=9dccaa3e023565a2ef5091b4a08da847872714ff) - - - NOTE: If any import declarations mention a module not found in any dependencies (like `Control.Lens` without `lens`), then typechecking fails, and the plugin can’t disambiguate the alias. This case should result in a meaningful error message. - - This also means disambiguation can only happen after typechecking is complete. (Also, typechecking and renaming can be thought of as happening in the same pass.[^1] [^2]) - - - ~~TODO~~ DONE: Handle when both a module and its re-exporter are imported, like this: - - ```haskell - import Control.Lens as L - import Control.Lens.Getter as L - - f = L.view - ``` - - - If the cursor is on `L` in `L.view`, then throw an error like “Can’t rename: There are 2 matching imports with the alias 'L'. Click on one of these 'L' aliases in the import declarations and try renaming again.” - - If the cursor is on `L` in either `as L` declaration, then rename all entities exported by the corresponding module (including re-exported ones). - -3. Traversing the AST is done using `listify` from `syb`. `listify` needs to be monomorphic. To apply the correct type, use the `XRec` and `Anno` type families. - -4. GHC source spans are in Unicode code points; LSP uses UTF-16 code units by default. - - **Avoid any direct arithmetic between `RealSrcSpan` and `Range`.** - - Use `Language.LSP.VFS.CodePointRange` instead.[^3] - -5. There are three key design questions, generated by Claude: - - > Here's step 5 — assembling the `WorkspaceEdit` and wiring everything together. A few design questions to settle before I write the code: - > - > 1. **Entry point**: should alias renaming be a separate branch inside the existing `renameProvider`/`prepareRenameProvider` handlers, or a completely separate handler registered alongside them? - > - > 2. **`prepareRenameProvider`**: for an alias rename, what should the prepare response return? The default range (the alias token at cursor) and the current alias text seems right, but should it return a `PrepareRenameResult` with a `defaultBehavior` or an explicit `range` + `placeholder`? - > - > 3. **Error handling**: if `getParsedModuleStale` returns `Nothing` (e.g. file hasn't been parsed yet), should we fall through to the existing name-based rename, or fail with an explicit error message? - > - > —Claude - - The following design decisions are taken by the author: - - 1. Add a branch inside the existing `Provider` handlers, because registering multiple `renameProvider` handlers would be difficult and impractical. The new alias-renaming branch takes place first in both handlers, ahead of the existing renaming logic. - - 2. `prepareRenameProvider` returns `PrepareRenameDefaultBehavior True` if the cursor is on a renameable alias. - - - TODO: PR #4867 replaces `defaultBehavior` with explicit `Range` responses. If it is accepted, modify this PR. - - 3. Fail early if HLS can’t get the parsed module, instead of falling through. The existing renaming logic also needs the module to be parsed (and then typechecked) anyway. - -[^1]: https://ghc-proposals.readthedocs.io/en/latest/proposals/0107-source-plugins.html - -[^2]: https://downloads.haskell.org/ghc/latest/docs/users_guide/extending_ghc.html - -[^3]: https://github.com/haskell/haskell-language-server/issues/2646#issuecomment-1024990401 From 42ad67ca09429e64771596e17b7f80a67e30deb2 Mon Sep 17 00:00:00 2001 From: izuzu Date: Mon, 30 Mar 2026 01:54:07 +0800 Subject: [PATCH 27/31] Allow renaming when cursor is at end of alias --- .../src/Ide/Plugin/Rename.hs | 8 ++++--- .../src/Ide/Plugin/Rename/ImportAlias.hs | 18 +++++++------- plugins/hls-rename-plugin/test/Main.hs | 24 ++++++++++++++++++- 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs index d0b021794b..5d7662855d 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs @@ -110,7 +110,7 @@ prepareRenameProvider state _pluginId (PrepareRenameParams (TextDocumentIdentifi HAR{hieAst} <- handleGetHieAst state nfp let spansWithNamesUnderCursor = [ srcSpan - | (names, srcSpan) <- getNamesSpansAtPoint' hieAst pos + | (names, srcSpan) <- getNamesSpansAtPoint' hieAst lspPos , not (null names)] -- 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. @@ -304,10 +304,10 @@ getCodePointPosition state nfp pos = do "The cursor position is inside a Unicode surrogate pair." Just codePointPosition -> pure codePointPosition --- TODO: 'getNamesAtPos' passes the LSP 'Position' directly to 'pointCommand', +-- FIXME: 'getNamesAtPos' passes the LSP 'Position' directly to 'pointCommand', -- which treats '_character' as a code-point column. This is incorrect for -- files with supplementary-plane Unicode characters before the cursor. --- Fixing it requires changes in ghcide, not here. +-- Fixing it requires changes to 'pointCommand' in ghcide, not here. getNamesAtPos :: MonadIO m => IdeState -> NormalizedFilePath -> Position -> ExceptT PluginError m [Name] getNamesAtPos state nfp pos = do HAR{hieAst} <- handleGetHieAst state nfp @@ -352,11 +352,13 @@ 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 -- | A variant 'getNamesAtPoint' that does not expect a 'PositionMapping' +-- FIXME: The use of 'pointCommand' is problematic. See 'getNamesAtPos' above. getNamesAtPoint' :: HieASTs a -> Position -> [Name] getNamesAtPoint' hf pos = concat $ pointCommand hf pos (rights . M.keys . getNodeIds) -- | A variant of `getNamesAtPoint'` that also returns source spans. +-- FIXME: The use of 'pointCommand' is problematic. See 'getNamesAtPos' above. getNamesSpansAtPoint' :: HieASTs a -> Position -> [([Name], RealSrcSpan)] getNamesSpansAtPoint' hf pos = pointCommand hf pos $ diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs index e27c4fbe1b..f8da4b798e 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs @@ -174,7 +174,7 @@ findAliasDeclAtPos pos imports = listToMaybe $ do Just locatedAlias <- [ideclAs importDecl] RealSrcSpan aliasDeclSpan _ <- [getLoc locatedAlias] let aliasDeclRange = realSrcSpanToCodePointRange aliasDeclSpan - guard (rangeContainsPosition aliasDeclRange pos) + guard (rangeContainsPositionInclusive aliasDeclRange pos) let aliasModuleName = unLoc (ideclName importDecl) aliasName = unLoc locatedAlias aliasIsShared = length (filter (== aliasName) allAliases) > 1 @@ -194,12 +194,12 @@ findAliasUseAtPos pos imports hsDecls = Qual qualifier _ <- [unLoc locatedRdrName] RealSrcSpan qualifiedNameSpan _ <- [getLoc locatedRdrName] let qualifiedNameRange = realSrcSpanToCodePointRange qualifiedNameSpan - guard (rangeContainsPosition qualifiedNameRange pos) + guard (rangeContainsPositionInclusive qualifiedNameRange pos) let qualifierLength = fromIntegral (moduleNameLength qualifier) qualifierStart = qualifiedNameRange ^. VFS.start qualifierRange = qualifiedNameRange & VFS.end .~ (qualifierStart & VFS.character +~ qualifierLength) - guard (rangeContainsPosition qualifierRange pos) + guard (rangeContainsPositionInclusive qualifierRange pos) [(qualifierRange, qualifier)] in case qualifiersAtPos of [] -> Nothing @@ -295,16 +295,18 @@ ambiguousAliasErrorMessage _ = "" --------------------------------------------------------------------------------------------------- -- Utility functions --- | Check whether a 'CodePointRange' contains a 'CodePointPosition' --- (inclusive start, exclusive end). -rangeContainsPosition :: VFS.CodePointRange -> VFS.CodePointPosition -> Bool -rangeContainsPosition +-- | Check whether a 'CodePointRange' contains a 'CodePointPosition' (inclusive +-- start, inclusive end). +-- NOTE: The use of inclusive end allows the user to place the cursor at the end +-- of an import alias and rename it. +rangeContainsPositionInclusive :: VFS.CodePointRange -> VFS.CodePointPosition -> Bool +rangeContainsPositionInclusive (VFS.CodePointRange (VFS.CodePointPosition startLine startColumn) (VFS.CodePointPosition endLine endColumn)) (VFS.CodePointPosition posLine posColumn) = (posLine > startLine || (posLine == startLine && posColumn >= startColumn)) - && (posLine < endLine || (posLine == endLine && posColumn < endColumn)) + && (posLine < endLine || (posLine == endLine && posColumn <= endColumn)) -- | Build a 'TextEdit' from a 'VFS.CodePointRange' and replacement text. -- Returns @Nothing@ if the range is out of bounds in the VFS. diff --git a/plugins/hls-rename-plugin/test/Main.hs b/plugins/hls-rename-plugin/test/Main.hs index 94bd51a5a6..dc9e57b408 100644 --- a/plugins/hls-rename-plugin/test/Main.hs +++ b/plugins/hls-rename-plugin/test/Main.hs @@ -37,6 +37,28 @@ prepareRenameTests = testGroup "PrepareRename" result <- prepareRename doc (Position 0 9) liftIO $ result @?= InR Null + , testCase "Import alias in declaration" $ runRenameSession "" $ do + doc <- openDoc "PrepareRename.hs" "haskell" + void waitForBuildQueue + let expected = InL (PrepareRenameResult (InL (Range (Position 2 24) (Position 2 25)))) + resultAtStart <- prepareRename doc (Position 2 24) + liftIO $ resultAtStart @?= expected + resultAtEnd <- prepareRename doc (Position 2 25) + liftIO $ resultAtEnd @?= expected + resultOutside <- prepareRename doc (Position 2 26) + liftIO $ resultOutside /= expected @? "Cursor is outside alias" + + , testCase "Import alias at use site" $ runRenameSession "" $ do + doc <- openDoc "PrepareRename.hs" "haskell" + void waitForBuildQueue + let expected = InL (PrepareRenameResult (InL (Range (Position 10 14) (Position 10 15)))) + resultAtStart <- prepareRename doc (Position 10 14) + liftIO $ resultAtStart @?= expected + resultAtEnd <- prepareRename doc (Position 10 15) + liftIO $ resultAtEnd @?= expected + resultOutside <- prepareRename doc (Position 10 16) + liftIO $ resultOutside /= expected @? "Cursor is outside qualifier" + , testCase "Function name" $ runRenameSession "" $ do doc <- openDoc "PrepareRename.hs" "haskell" void waitForBuildQueue @@ -96,7 +118,7 @@ renameTests = testGroup "Identifier" , goldenWithRename "Import alias declaration" "ImportAlias" $ \doc -> rename doc (Position 1 14) "G" , goldenWithRename "Import alias at use site" "ImportAlias" $ \doc -> - rename doc (Position 5 6) "G" + rename doc (Position 5 10) "G" , goldenWithRename "Import alias declaration (shared by unrelated imports)" "ImportAliasShared" $ \doc -> rename doc (Position 1 31) "Maybe" , goldenWithRename "Import alias at use site (shared by unrelated imports)" "ImportAliasShared" $ \doc -> From 9df3d3b3d29b3478916fd5b8f69a0b8f7a3a3467 Mon Sep 17 00:00:00 2001 From: izuzu Date: Mon, 30 Mar 2026 02:31:41 +0800 Subject: [PATCH 28/31] Rename alias in re-exported names This commit implements renaming an alias in qualified names in the module export list, such as from `module Main (M.isJust) where` to `module Main (Maybe.isJust) where`. --- .../src/Ide/Plugin/Rename.hs | 8 +++-- .../src/Ide/Plugin/Rename/ImportAlias.hs | 35 ++++++++++++------- plugins/hls-rename-plugin/test/Main.hs | 11 ++++-- .../testdata/ImportAliasShared.expected.hs | 2 ++ .../test/testdata/ImportAliasShared.hs | 2 ++ .../test/testdata/PrepareRename.hs | 2 +- 6 files changed, 42 insertions(+), 18 deletions(-) diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs index 5d7662855d..e55c58a118 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs @@ -99,10 +99,11 @@ prepareRenameProvider state _pluginId (PrepareRenameParams (TextDocumentIdentifi "The module hasn’t yet been parsed. Please wait for indexing to complete and try again." Just parsed -> do let hsModule = unLoc $ pm_parsed_source parsed + exports = hsmodExports hsModule imports = hsmodImports hsModule hsDecls = hsmodDecls hsModule maybeAlias <- ImportAlias.resolveAliasAtPos - getNamesAtPos state nfp lspPos codePointPos imports hsDecls + getNamesAtPos state nfp lspPos codePointPos exports imports hsDecls case maybeAlias of Just (lspRange, _importAlias) -> pure $ InL $ PrepareRenameResult $ InL $ lspRange @@ -134,13 +135,14 @@ renameProvider state pluginId (RenameParams _prog (TextDocumentIdentifier uri) l "The module hasn’t yet been parsed. Please wait for indexing to complete and try again." Just parsed -> do let hsModule = unLoc $ pm_parsed_source parsed + exports = hsmodExports hsModule imports = hsmodImports hsModule hsDecls = hsmodDecls hsModule maybeAlias <- ImportAlias.resolveAliasAtPos - getNamesAtPos state nfp lspPos codePointPos imports hsDecls + getNamesAtPos state nfp lspPos codePointPos exports imports hsDecls case maybeAlias of Just (_lspRange, importAlias) -> ImportAlias.aliasBasedRename - state nfp uri importAlias hsDecls newNameText + state nfp uri importAlias exports hsDecls newNameText Nothing -> nameBasedRename state pluginId nfp lspPos newNameText diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs index f8da4b798e..9dee126198 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs @@ -101,10 +101,11 @@ resolveAliasAtPos :: NormalizedFilePath -> LSP.Position -> VFS.CodePointPosition -> + Maybe (XRec GhcPs [LIE GhcPs]) -> [LImportDecl GhcPs] -> [LHsDecl GhcPs] -> ExceptT PluginError m (Maybe (LSP.Range, ImportAlias)) -resolveAliasAtPos getNamesAtPosFn state nfp lspPos pos imports hsDecls = do +resolveAliasAtPos getNamesAtPosFn state nfp lspPos pos exports imports hsDecls = do virtualFile <- runActionE "rename.getVirtualFile" state $ handleMaybeM (PluginInternalError ("Virtual file not found: " <> T.pack (show nfp))) @@ -114,7 +115,7 @@ resolveAliasAtPos getNamesAtPosFn state nfp lspPos pos imports hsDecls = do Just lspRange -> Just (lspRange, alias) case findAliasDeclAtPos pos imports of Just alias -> pure $ toLSPRange (aliasDeclRange alias, alias) - Nothing -> case findAliasUseAtPos pos imports hsDecls of + Nothing -> case findAliasUseAtPos pos exports imports hsDecls of Nothing -> pure Nothing Just (_, []) -> pure Nothing Just (range, [alias]) -> pure $ toLSPRange (range, alias) @@ -134,10 +135,11 @@ aliasBasedRename :: NormalizedFilePath -> Uri -> ImportAlias -> + Maybe (XRec GhcPs [LIE GhcPs]) -> [LHsDecl GhcPs] -> T.Text -> ExceptT PluginError m (MessageResult Method_TextDocumentRename) -aliasBasedRename state nfp uri importAlias hsDecls newNameText = do +aliasBasedRename state nfp uri importAlias exports hsDecls newNameText = do let ImportAlias{aliasDeclRange, aliasIsShared} = importAlias virtualFile <- runActionE "rename.getVirtualFile" state $ handleMaybeM (PluginInternalError @@ -147,9 +149,9 @@ aliasBasedRename state nfp uri importAlias hsDecls newNameText = do if aliasIsShared then do tcModule <- runActionE "rename.sharedAliasRanges" state $ useE TypeCheck nfp - pure $ aliasUseSiteRangesDisambiguated tcModule importAlias hsDecls + pure $ aliasUseSiteRangesDisambiguated tcModule importAlias exports hsDecls else - pure $ aliasUseSiteRanges importAlias hsDecls + pure $ aliasUseSiteRanges importAlias exports hsDecls declEdit <- handleMaybe (PluginInternalError "Alias declaration span is out of range") $ rangeToTextEdit virtualFile newNameText aliasDeclRange useEdits <- handleMaybe (PluginInternalError "A use site span is out of range") @@ -185,12 +187,14 @@ findAliasDeclAtPos pos imports = listToMaybe $ do -- Returns multiple aliases if multiple modules share the same alias. findAliasUseAtPos :: VFS.CodePointPosition -> + Maybe (XRec GhcPs [LIE GhcPs]) -> [LImportDecl GhcPs] -> [LHsDecl GhcPs] -> Maybe (VFS.CodePointRange, [ImportAlias]) -findAliasUseAtPos pos imports hsDecls = +findAliasUseAtPos pos exports imports hsDecls = let qualifiersAtPos = do - locatedRdrName :: XRec GhcPs RdrName <- listify (const True) hsDecls + locatedRdrName :: XRec GhcPs RdrName <- + listify (const True) exports ++ listify (const True) hsDecls Qual qualifier _ <- [unLoc locatedRdrName] RealSrcSpan qualifiedNameSpan _ <- [getLoc locatedRdrName] let qualifiedNameRange = realSrcSpanToCodePointRange qualifiedNameSpan @@ -217,11 +221,16 @@ findAliasUseAtPos pos imports hsDecls = -- | Collect the 'CodePointRange' of every qualified use of @importAlias@, such -- as @L@ in @L.take@, @L.drop@, and so on. -aliasUseSiteRanges :: ImportAlias -> [LHsDecl GhcPs] -> [VFS.CodePointRange] -aliasUseSiteRanges importAlias hsDecls = nubOrd $ do +aliasUseSiteRanges :: + ImportAlias -> + Maybe (XRec GhcPs [LIE GhcPs]) -> + [LHsDecl GhcPs] -> + [VFS.CodePointRange] +aliasUseSiteRanges importAlias exports hsDecls = nubOrd $ do let ImportAlias{aliasName} = importAlias aliasLength = fromIntegral (moduleNameLength aliasName) - locatedRdrName :: XRec GhcPs RdrName <- listify (const True) hsDecls + locatedRdrName :: XRec GhcPs RdrName <- + listify (const True) exports ++ listify (const True) hsDecls Qual qualifier _ <- [unLoc locatedRdrName] guard (qualifier == aliasName) RealSrcSpan qualifiedNameSpan _ <- [getLoc locatedRdrName] @@ -259,13 +268,15 @@ disambiguateAliasUse tcModule namesAtPos candidates = nubOrd $ do aliasUseSiteRangesDisambiguated :: TcModuleResult -> ImportAlias -> + Maybe (XRec GhcPs [LIE GhcPs]) -> [LHsDecl GhcPs] -> [VFS.CodePointRange] -aliasUseSiteRangesDisambiguated tcModule importAlias hsDecls = nubOrd $ do +aliasUseSiteRangesDisambiguated tcModule importAlias exports hsDecls = nubOrd $ do let rdrEnv = tcg_rdr_env (tmrTypechecked tcModule) ImportAlias{aliasModuleName, aliasName} = importAlias aliasLength = fromIntegral (moduleNameLength aliasName) - locatedRdrName :: XRec GhcPs RdrName <- listify (const True) hsDecls + locatedRdrName :: XRec GhcPs RdrName <- + listify (const True) exports ++ listify (const True) hsDecls rdrName@(Qual qualifier name) <- [unLoc locatedRdrName] guard (qualifier == aliasName) nameGREElement <- pickGREs rdrName $ lookupGlobalRdrEnv rdrEnv name diff --git a/plugins/hls-rename-plugin/test/Main.hs b/plugins/hls-rename-plugin/test/Main.hs index dc9e57b408..23a5954a41 100644 --- a/plugins/hls-rename-plugin/test/Main.hs +++ b/plugins/hls-rename-plugin/test/Main.hs @@ -59,6 +59,13 @@ prepareRenameTests = testGroup "PrepareRename" resultOutside <- prepareRename doc (Position 10 16) liftIO $ resultOutside /= expected @? "Cursor is outside qualifier" + , testCase "Import alias in re-export" $ runRenameSession "" $ do + doc <- openDoc "PrepareRename.hs" "haskell" + void waitForBuildQueue + result <- prepareRename doc (Position 0 27) + liftIO $ result @?= + InL (PrepareRenameResult (InL (Range (Position 0 27) (Position 0 28)))) + , testCase "Function name" $ runRenameSession "" $ do doc <- openDoc "PrepareRename.hs" "haskell" void waitForBuildQueue @@ -120,9 +127,9 @@ renameTests = testGroup "Identifier" , goldenWithRename "Import alias at use site" "ImportAlias" $ \doc -> rename doc (Position 5 10) "G" , goldenWithRename "Import alias declaration (shared by unrelated imports)" "ImportAliasShared" $ \doc -> - rename doc (Position 1 31) "Maybe" + rename doc (Position 3 31) "Maybe" , goldenWithRename "Import alias at use site (shared by unrelated imports)" "ImportAliasShared" $ \doc -> - rename doc (Position 4 6) "Maybe" + rename doc (Position 6 6) "Maybe" , goldenWithRename "Import alias declaration (with re-exports)" "ImportAliasReexport" $ \doc -> do rename doc (Position 1 18) "Reexport" , testCase "Import alias at use site (ambiguous due to re-exports)" $ runRenameSession "" $ do diff --git a/plugins/hls-rename-plugin/test/testdata/ImportAliasShared.expected.hs b/plugins/hls-rename-plugin/test/testdata/ImportAliasShared.expected.hs index 93549824ea..7f7f28aa2b 100644 --- a/plugins/hls-rename-plugin/test/testdata/ImportAliasShared.expected.hs +++ b/plugins/hls-rename-plugin/test/testdata/ImportAliasShared.expected.hs @@ -1,3 +1,5 @@ +module ImportAliasShared (Maybe.fromMaybe, M.mapM) where + import qualified Control.Monad as M import qualified Data.Maybe as Maybe diff --git a/plugins/hls-rename-plugin/test/testdata/ImportAliasShared.hs b/plugins/hls-rename-plugin/test/testdata/ImportAliasShared.hs index 133efe693f..20fecf5b42 100644 --- a/plugins/hls-rename-plugin/test/testdata/ImportAliasShared.hs +++ b/plugins/hls-rename-plugin/test/testdata/ImportAliasShared.hs @@ -1,3 +1,5 @@ +module ImportAliasShared (M.fromMaybe, M.mapM) where + import qualified Control.Monad as M import qualified Data.Maybe as M diff --git a/plugins/hls-rename-plugin/test/testdata/PrepareRename.hs b/plugins/hls-rename-plugin/test/testdata/PrepareRename.hs index e5271d3454..7e3f5f1769 100644 --- a/plugins/hls-rename-plugin/test/testdata/PrepareRename.hs +++ b/plugins/hls-rename-plugin/test/testdata/PrepareRename.hs @@ -1,4 +1,4 @@ -module PrepareRename where +module PrepareRename (bar, F.foo) where import qualified Foo as F From ef4e33bcd5cd8d3fa5a3bf0271d90e856fdf5e84 Mon Sep 17 00:00:00 2001 From: izuzu Date: Wed, 1 Apr 2026 23:35:09 +0800 Subject: [PATCH 29/31] Demonstrate that `listify` is lazy In `findAliasUseAtPos`, `listify` only produces `RdrName` elements until it finds one whose alias is at the cursor. This element is consumed through pattern matching; `listify` then stops traversing and produces no more elements. See the new test cases in `Main.hs`. --- .../src/Ide/Plugin/Rename/ImportAlias.hs | 33 ++++++++++++++----- plugins/hls-rename-plugin/test/Main.hs | 18 ++++++++++ .../ImportAliasLazyListify.expected.hs | 13 ++++++++ .../test/testdata/ImportAliasLazyListify.hs | 13 ++++++++ 4 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 plugins/hls-rename-plugin/test/testdata/ImportAliasLazyListify.expected.hs create mode 100644 plugins/hls-rename-plugin/test/testdata/ImportAliasLazyListify.hs diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs index 9dee126198..7fd2a756a4 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs @@ -1,6 +1,6 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE OverloadedStrings #-} -{-# OPTIONS_GHC -Wall -Werror #-} +{-# OPTIONS_GHC -Wall #-} {-| Logic for renaming qualified import aliases. @@ -47,6 +47,7 @@ import Data.Generics import qualified Data.Map as M import Data.Maybe import qualified Data.Text as T +import Debug.Trace (trace, traceShowWith) import Development.IDE (realSrcSpanToCodePointRange) import Development.IDE.Core.FileStore (getVersionedTextDoc) import Development.IDE.Core.PluginUtils @@ -55,6 +56,7 @@ import Development.IDE.Core.Service hiding (Log) import Development.IDE.Core.Shake hiding (Log) import Development.IDE.GHC.Compat hiding (importDecl) import GHC.Data.FastString (lengthFS) +import GHC.Utils.Outputable (showPprUnsafe) import Ide.Plugin.Error import qualified Language.LSP.Protocol.Lens as L import Language.LSP.Protocol.Message @@ -193,8 +195,7 @@ findAliasUseAtPos :: Maybe (VFS.CodePointRange, [ImportAlias]) findAliasUseAtPos pos exports imports hsDecls = let qualifiersAtPos = do - locatedRdrName :: XRec GhcPs RdrName <- - listify (const True) exports ++ listify (const True) hsDecls + locatedRdrName <- locateRdrNames exports hsDecls True Qual qualifier _ <- [unLoc locatedRdrName] RealSrcSpan qualifiedNameSpan _ <- [getLoc locatedRdrName] let qualifiedNameRange = realSrcSpanToCodePointRange qualifiedNameSpan @@ -204,7 +205,7 @@ findAliasUseAtPos pos exports imports hsDecls = qualifierRange = qualifiedNameRange & VFS.end .~ (qualifierStart & VFS.character +~ qualifierLength) guard (rangeContainsPositionInclusive qualifierRange pos) - [(qualifierRange, qualifier)] + traceShowWith (\x -> "findAliasUseAtPos/qualifierAtPos: " <> show x) [(qualifierRange, qualifier)] in case qualifiersAtPos of [] -> Nothing (rangeAtPos, qualifierAtPos) : _ -> Just $ (,) rangeAtPos $ do @@ -229,8 +230,7 @@ aliasUseSiteRanges :: aliasUseSiteRanges importAlias exports hsDecls = nubOrd $ do let ImportAlias{aliasName} = importAlias aliasLength = fromIntegral (moduleNameLength aliasName) - locatedRdrName :: XRec GhcPs RdrName <- - listify (const True) exports ++ listify (const True) hsDecls + locatedRdrName <- locateRdrNames exports hsDecls False Qual qualifier _ <- [unLoc locatedRdrName] guard (qualifier == aliasName) RealSrcSpan qualifiedNameSpan _ <- [getLoc locatedRdrName] @@ -275,8 +275,7 @@ aliasUseSiteRangesDisambiguated tcModule importAlias exports hsDecls = nubOrd $ let rdrEnv = tcg_rdr_env (tmrTypechecked tcModule) ImportAlias{aliasModuleName, aliasName} = importAlias aliasLength = fromIntegral (moduleNameLength aliasName) - locatedRdrName :: XRec GhcPs RdrName <- - listify (const True) exports ++ listify (const True) hsDecls + locatedRdrName <- locateRdrNames exports hsDecls False rdrName@(Qual qualifier name) <- [unLoc locatedRdrName] guard (qualifier == aliasName) nameGREElement <- pickGREs rdrName $ lookupGlobalRdrEnv rdrEnv name @@ -306,6 +305,24 @@ ambiguousAliasErrorMessage _ = "" --------------------------------------------------------------------------------------------------- -- Utility functions +instance Show RdrName where + show (Unqual name) = show name + show (Qual moduleName name) = show moduleName ++ "." ++ show name + show (Orig mod name) = "Orig: " <> show mod <> "." <> show name + show (Exact name) = "Exact: " <> showPprUnsafe name + +-- | Locate 'RdrName' identifiers in the given export list and declarations. +locateRdrNames :: + Maybe (XRec GhcPs [LIE GhcPs]) -> + [LHsDecl GhcPs] -> + Bool -> + [XRec GhcPs RdrName] +locateRdrNames exports hsDecls traceEveryProducedName = + listify const_True exports ++ listify const_True hsDecls + where const_True = if traceEveryProducedName + then \x -> trace ("listify: " <> show (unLoc x) <> " at " <> show (getLoc x)) True + else const True + -- | Check whether a 'CodePointRange' contains a 'CodePointPosition' (inclusive -- start, inclusive end). -- NOTE: The use of inclusive end allows the user to place the cursor at the end diff --git a/plugins/hls-rename-plugin/test/Main.hs b/plugins/hls-rename-plugin/test/Main.hs index 23a5954a41..a1229468e5 100644 --- a/plugins/hls-rename-plugin/test/Main.hs +++ b/plugins/hls-rename-plugin/test/Main.hs @@ -130,6 +130,24 @@ renameTests = testGroup "Identifier" rename doc (Position 3 31) "Maybe" , goldenWithRename "Import alias at use site (shared by unrelated imports)" "ImportAliasShared" $ \doc -> rename doc (Position 6 6) "Maybe" + + -- REVIEW: `listify (const True) exports` produces 2 elements. The 2nd one, + -- `M.isJust`, has its alias at the cursor. The `qualifiersAtPos` pattern + -- match consumes it, and then doesn't consume any more elements. `listify + -- exports` then stops, and `listify hsDecls` is not evaluated at all. + -- The console shows 2 `listify:` traces. + , goldenWithRename "Import alias in export list (proving 'listify' is lazy)" "ImportAliasLazyListify" $ \doc -> + rename doc (Position 0 39) "Maybe" + + -- REVIEW: `listify (const True) exports ++ listify (const True) hsDecls` + -- produces elements until it produces one whose alias is at the cursor. + -- This element is then consumed in the pattern match. `listify` then stops + -- producing. + -- The console shows traces for all `RdrName`s except for the `M.fromMaybe` + -- on the last line. + , goldenWithRename "Import alias in definition (proving 'listify' is lazy)" "ImportAliasLazyListify" $ \doc -> + rename doc (Position 9 6) "Maybe" + , goldenWithRename "Import alias declaration (with re-exports)" "ImportAliasReexport" $ \doc -> do rename doc (Position 1 18) "Reexport" , testCase "Import alias at use site (ambiguous due to re-exports)" $ runRenameSession "" $ do diff --git a/plugins/hls-rename-plugin/test/testdata/ImportAliasLazyListify.expected.hs b/plugins/hls-rename-plugin/test/testdata/ImportAliasLazyListify.expected.hs new file mode 100644 index 0000000000..08a0e1927f --- /dev/null +++ b/plugins/hls-rename-plugin/test/testdata/ImportAliasLazyListify.expected.hs @@ -0,0 +1,13 @@ +module ImportAliasShared (Maybe.fromMaybe, Maybe.isJust, Maybe.maybe, M.mapM) where + +import qualified Control.Monad as M +import qualified Data.Maybe as Maybe + +bar :: Maybe a -> Bool +bar = Maybe.isJust + +baz :: b -> (a -> b) -> Maybe a -> b +baz = Maybe.maybe + +buzz :: a -> Maybe a -> a +buzz = Maybe.fromMaybe diff --git a/plugins/hls-rename-plugin/test/testdata/ImportAliasLazyListify.hs b/plugins/hls-rename-plugin/test/testdata/ImportAliasLazyListify.hs new file mode 100644 index 0000000000..a0b82b360e --- /dev/null +++ b/plugins/hls-rename-plugin/test/testdata/ImportAliasLazyListify.hs @@ -0,0 +1,13 @@ +module ImportAliasShared (M.fromMaybe, M.isJust, M.maybe, M.mapM) where + +import qualified Control.Monad as M +import qualified Data.Maybe as M + +bar :: Maybe a -> Bool +bar = M.isJust + +baz :: b -> (a -> b) -> Maybe a -> b +baz = M.maybe + +buzz :: a -> Maybe a -> a +buzz = M.fromMaybe From c5ca3b1b1eb49aaf19f0dfb05c06528fccda724b Mon Sep 17 00:00:00 2001 From: izuzu Date: Fri, 3 Apr 2026 00:01:28 +0800 Subject: [PATCH 30/31] Revert "Demonstrate that `listify` is lazy" This reverts commit ef4e33bcd5cd8d3fa5a3bf0271d90e856fdf5e84. --- .../src/Ide/Plugin/Rename/ImportAlias.hs | 33 +++++-------------- plugins/hls-rename-plugin/test/Main.hs | 18 ---------- .../ImportAliasLazyListify.expected.hs | 13 -------- .../test/testdata/ImportAliasLazyListify.hs | 13 -------- 4 files changed, 8 insertions(+), 69 deletions(-) delete mode 100644 plugins/hls-rename-plugin/test/testdata/ImportAliasLazyListify.expected.hs delete mode 100644 plugins/hls-rename-plugin/test/testdata/ImportAliasLazyListify.hs diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs index 7fd2a756a4..9dee126198 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs @@ -1,6 +1,6 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE OverloadedStrings #-} -{-# OPTIONS_GHC -Wall #-} +{-# OPTIONS_GHC -Wall -Werror #-} {-| Logic for renaming qualified import aliases. @@ -47,7 +47,6 @@ import Data.Generics import qualified Data.Map as M import Data.Maybe import qualified Data.Text as T -import Debug.Trace (trace, traceShowWith) import Development.IDE (realSrcSpanToCodePointRange) import Development.IDE.Core.FileStore (getVersionedTextDoc) import Development.IDE.Core.PluginUtils @@ -56,7 +55,6 @@ import Development.IDE.Core.Service hiding (Log) import Development.IDE.Core.Shake hiding (Log) import Development.IDE.GHC.Compat hiding (importDecl) import GHC.Data.FastString (lengthFS) -import GHC.Utils.Outputable (showPprUnsafe) import Ide.Plugin.Error import qualified Language.LSP.Protocol.Lens as L import Language.LSP.Protocol.Message @@ -195,7 +193,8 @@ findAliasUseAtPos :: Maybe (VFS.CodePointRange, [ImportAlias]) findAliasUseAtPos pos exports imports hsDecls = let qualifiersAtPos = do - locatedRdrName <- locateRdrNames exports hsDecls True + locatedRdrName :: XRec GhcPs RdrName <- + listify (const True) exports ++ listify (const True) hsDecls Qual qualifier _ <- [unLoc locatedRdrName] RealSrcSpan qualifiedNameSpan _ <- [getLoc locatedRdrName] let qualifiedNameRange = realSrcSpanToCodePointRange qualifiedNameSpan @@ -205,7 +204,7 @@ findAliasUseAtPos pos exports imports hsDecls = qualifierRange = qualifiedNameRange & VFS.end .~ (qualifierStart & VFS.character +~ qualifierLength) guard (rangeContainsPositionInclusive qualifierRange pos) - traceShowWith (\x -> "findAliasUseAtPos/qualifierAtPos: " <> show x) [(qualifierRange, qualifier)] + [(qualifierRange, qualifier)] in case qualifiersAtPos of [] -> Nothing (rangeAtPos, qualifierAtPos) : _ -> Just $ (,) rangeAtPos $ do @@ -230,7 +229,8 @@ aliasUseSiteRanges :: aliasUseSiteRanges importAlias exports hsDecls = nubOrd $ do let ImportAlias{aliasName} = importAlias aliasLength = fromIntegral (moduleNameLength aliasName) - locatedRdrName <- locateRdrNames exports hsDecls False + locatedRdrName :: XRec GhcPs RdrName <- + listify (const True) exports ++ listify (const True) hsDecls Qual qualifier _ <- [unLoc locatedRdrName] guard (qualifier == aliasName) RealSrcSpan qualifiedNameSpan _ <- [getLoc locatedRdrName] @@ -275,7 +275,8 @@ aliasUseSiteRangesDisambiguated tcModule importAlias exports hsDecls = nubOrd $ let rdrEnv = tcg_rdr_env (tmrTypechecked tcModule) ImportAlias{aliasModuleName, aliasName} = importAlias aliasLength = fromIntegral (moduleNameLength aliasName) - locatedRdrName <- locateRdrNames exports hsDecls False + locatedRdrName :: XRec GhcPs RdrName <- + listify (const True) exports ++ listify (const True) hsDecls rdrName@(Qual qualifier name) <- [unLoc locatedRdrName] guard (qualifier == aliasName) nameGREElement <- pickGREs rdrName $ lookupGlobalRdrEnv rdrEnv name @@ -305,24 +306,6 @@ ambiguousAliasErrorMessage _ = "" --------------------------------------------------------------------------------------------------- -- Utility functions -instance Show RdrName where - show (Unqual name) = show name - show (Qual moduleName name) = show moduleName ++ "." ++ show name - show (Orig mod name) = "Orig: " <> show mod <> "." <> show name - show (Exact name) = "Exact: " <> showPprUnsafe name - --- | Locate 'RdrName' identifiers in the given export list and declarations. -locateRdrNames :: - Maybe (XRec GhcPs [LIE GhcPs]) -> - [LHsDecl GhcPs] -> - Bool -> - [XRec GhcPs RdrName] -locateRdrNames exports hsDecls traceEveryProducedName = - listify const_True exports ++ listify const_True hsDecls - where const_True = if traceEveryProducedName - then \x -> trace ("listify: " <> show (unLoc x) <> " at " <> show (getLoc x)) True - else const True - -- | Check whether a 'CodePointRange' contains a 'CodePointPosition' (inclusive -- start, inclusive end). -- NOTE: The use of inclusive end allows the user to place the cursor at the end diff --git a/plugins/hls-rename-plugin/test/Main.hs b/plugins/hls-rename-plugin/test/Main.hs index a1229468e5..23a5954a41 100644 --- a/plugins/hls-rename-plugin/test/Main.hs +++ b/plugins/hls-rename-plugin/test/Main.hs @@ -130,24 +130,6 @@ renameTests = testGroup "Identifier" rename doc (Position 3 31) "Maybe" , goldenWithRename "Import alias at use site (shared by unrelated imports)" "ImportAliasShared" $ \doc -> rename doc (Position 6 6) "Maybe" - - -- REVIEW: `listify (const True) exports` produces 2 elements. The 2nd one, - -- `M.isJust`, has its alias at the cursor. The `qualifiersAtPos` pattern - -- match consumes it, and then doesn't consume any more elements. `listify - -- exports` then stops, and `listify hsDecls` is not evaluated at all. - -- The console shows 2 `listify:` traces. - , goldenWithRename "Import alias in export list (proving 'listify' is lazy)" "ImportAliasLazyListify" $ \doc -> - rename doc (Position 0 39) "Maybe" - - -- REVIEW: `listify (const True) exports ++ listify (const True) hsDecls` - -- produces elements until it produces one whose alias is at the cursor. - -- This element is then consumed in the pattern match. `listify` then stops - -- producing. - -- The console shows traces for all `RdrName`s except for the `M.fromMaybe` - -- on the last line. - , goldenWithRename "Import alias in definition (proving 'listify' is lazy)" "ImportAliasLazyListify" $ \doc -> - rename doc (Position 9 6) "Maybe" - , goldenWithRename "Import alias declaration (with re-exports)" "ImportAliasReexport" $ \doc -> do rename doc (Position 1 18) "Reexport" , testCase "Import alias at use site (ambiguous due to re-exports)" $ runRenameSession "" $ do diff --git a/plugins/hls-rename-plugin/test/testdata/ImportAliasLazyListify.expected.hs b/plugins/hls-rename-plugin/test/testdata/ImportAliasLazyListify.expected.hs deleted file mode 100644 index 08a0e1927f..0000000000 --- a/plugins/hls-rename-plugin/test/testdata/ImportAliasLazyListify.expected.hs +++ /dev/null @@ -1,13 +0,0 @@ -module ImportAliasShared (Maybe.fromMaybe, Maybe.isJust, Maybe.maybe, M.mapM) where - -import qualified Control.Monad as M -import qualified Data.Maybe as Maybe - -bar :: Maybe a -> Bool -bar = Maybe.isJust - -baz :: b -> (a -> b) -> Maybe a -> b -baz = Maybe.maybe - -buzz :: a -> Maybe a -> a -buzz = Maybe.fromMaybe diff --git a/plugins/hls-rename-plugin/test/testdata/ImportAliasLazyListify.hs b/plugins/hls-rename-plugin/test/testdata/ImportAliasLazyListify.hs deleted file mode 100644 index a0b82b360e..0000000000 --- a/plugins/hls-rename-plugin/test/testdata/ImportAliasLazyListify.hs +++ /dev/null @@ -1,13 +0,0 @@ -module ImportAliasShared (M.fromMaybe, M.isJust, M.maybe, M.mapM) where - -import qualified Control.Monad as M -import qualified Data.Maybe as M - -bar :: Maybe a -> Bool -bar = M.isJust - -baz :: b -> (a -> b) -> Maybe a -> b -baz = M.maybe - -buzz :: a -> Maybe a -> a -buzz = M.fromMaybe From fd535c2e56ab6cb12825d777dddfa309064c076e Mon Sep 17 00:00:00 2001 From: izuzu Date: Fri, 3 Apr 2026 03:02:02 +0800 Subject: [PATCH 31/31] Resolve PR comments This commit addresses the following comments: - Validating the new name during alias renaming - Replacing repeated code with utility functions - Inlining `case` expressions - Adding explicit tests for when the cursor is at the end of an alias --- .../src/Ide/Plugin/Rename.hs | 2 +- .../src/Ide/Plugin/Rename/ImportAlias.hs | 119 ++++++++++++------ plugins/hls-rename-plugin/test/Main.hs | 18 +++ 3 files changed, 102 insertions(+), 37 deletions(-) diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs index e55c58a118..25c8bc6c48 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs @@ -146,7 +146,7 @@ renameProvider state pluginId (RenameParams _prog (TextDocumentIdentifier uri) l Nothing -> nameBasedRename state pluginId nfp lspPos newNameText --- | Name-based rename: the original rename logic. +-- | Logic for renaming all occurrences of a 'Name'. nameBasedRename :: IdeState -> PluginId -> diff --git a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs index 9dee126198..aacbcd5b77 100644 --- a/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs +++ b/plugins/hls-rename-plugin/src/Ide/Plugin/Rename/ImportAlias.hs @@ -1,18 +1,10 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE OverloadedStrings #-} -{-# OPTIONS_GHC -Wall -Werror #-} +{-# OPTIONS_GHC -Wall -Werror #-} {-| Logic for renaming qualified import aliases. -For example: - -> -- Before: --------------------------- -> import qualified Data.List as L -> bar = L.take -> -- After: ---------------------------- -> import qualified Data.List as List -> bar = List.take - The basic approach is this: 1. Get the parsed AST and see if there is an import alias at the cursor. @@ -38,7 +30,7 @@ module Ide.Plugin.Rename.ImportAlias ) where import Control.Lens ((&), (+~), (.~), (^.)) -import Control.Monad (guard) +import Control.Monad (guard, when) import Control.Monad.Except (ExceptT, MonadError (throwError)) import Control.Monad.IO.Class (MonadIO, liftIO) @@ -54,7 +46,9 @@ import Development.IDE.Core.RuleTypes import Development.IDE.Core.Service hiding (Log) import Development.IDE.Core.Shake hiding (Log) import Development.IDE.GHC.Compat hiding (importDecl) +import Development.IDE.GHC.Compat.Util (stringToStringBuffer) import GHC.Data.FastString (lengthFS) +import qualified GHC.Utils.Error as GHC import Ide.Plugin.Error import qualified Language.LSP.Protocol.Lens as L import Language.LSP.Protocol.Message @@ -125,8 +119,17 @@ resolveAliasAtPos getNamesAtPosFn state nfp lspPos pos exports imports hsDecls = case disambiguateAliasUse tcModule namesAtPos candidates of [] -> pure Nothing [alias] -> pure $ toLSPRange (range, alias) - aliases -> throwError $ PluginInvalidParams $ - ambiguousAliasErrorMessage aliases + aliases@(alias1 : alias2 : _) -> throwError $ PluginInvalidParams $ + let aliasCount = T.pack (show (length aliases)) + aliasText = moduleNameText (aliasName alias1) + module1 = moduleNameText (aliasModuleName alias1) + module2 = moduleNameText (aliasModuleName alias2) + in "Alias " <> quote aliasText + <> " is ambiguous (matching " <> aliasCount + <> " imports, including " <> quote module1 + <> " and " <> quote module2 + <> "). Try renaming " <> quote aliasText + <> " in one of these import declarations directly." -- | Build a 'WorkspaceEdit' renaming an import alias and all its use sites. aliasBasedRename :: @@ -140,6 +143,8 @@ aliasBasedRename :: T.Text -> ExceptT PluginError m (MessageResult Method_TextDocumentRename) aliasBasedRename state nfp uri importAlias exports hsDecls newNameText = do + when (not (isValidAlias newNameText)) $ + throwError (PluginInvalidParams (quote newNameText <> " is an invalid import alias.")) let ImportAlias{aliasDeclRange, aliasIsShared} = importAlias virtualFile <- runActionE "rename.getVirtualFile" state $ handleMaybeM (PluginInternalError @@ -160,7 +165,7 @@ aliasBasedRename state nfp uri importAlias exports hsDecls newNameText = do verTxtDocId <- liftIO $ runAction "rename.getVersionedTextDoc" state $ getVersionedTextDoc (TextDocumentIdentifier uri) let fileChanges = Just $ M.singleton (verTxtDocId ^. L.uri) allEdits - -- TODO: Replace 'Nothing' with meaningful details for the workspace edit. + -- TODO: Replace 'Nothing' with meaningful details (`ChangeAnnotation`). workspaceEdit = WorkspaceEdit fileChanges Nothing Nothing pure $ InL workspaceEdit @@ -179,7 +184,10 @@ findAliasDeclAtPos pos imports = listToMaybe $ do guard (rangeContainsPositionInclusive aliasDeclRange pos) let aliasModuleName = unLoc (ideclName importDecl) aliasName = unLoc locatedAlias - aliasIsShared = length (filter (== aliasName) allAliases) > 1 + aliasIsShared = case filter (== aliasName) allAliases of + [] -> False + [_] -> False + (_ : _ : _) -> True [ImportAlias{aliasModuleName, aliasName, aliasDeclRange, aliasIsShared}] -- | Find the text range and matching 'ImportAlias' for the name qualifier at @@ -193,8 +201,7 @@ findAliasUseAtPos :: Maybe (VFS.CodePointRange, [ImportAlias]) findAliasUseAtPos pos exports imports hsDecls = let qualifiersAtPos = do - locatedRdrName :: XRec GhcPs RdrName <- - listify (const True) exports ++ listify (const True) hsDecls + locatedRdrName <- locateRdrNames exports hsDecls Qual qualifier _ <- [unLoc locatedRdrName] RealSrcSpan qualifiedNameSpan _ <- [getLoc locatedRdrName] let qualifiedNameRange = realSrcSpanToCodePointRange qualifiedNameSpan @@ -229,8 +236,7 @@ aliasUseSiteRanges :: aliasUseSiteRanges importAlias exports hsDecls = nubOrd $ do let ImportAlias{aliasName} = importAlias aliasLength = fromIntegral (moduleNameLength aliasName) - locatedRdrName :: XRec GhcPs RdrName <- - listify (const True) exports ++ listify (const True) hsDecls + locatedRdrName <- locateRdrNames exports hsDecls Qual qualifier _ <- [unLoc locatedRdrName] guard (qualifier == aliasName) RealSrcSpan qualifiedNameSpan _ <- [getLoc locatedRdrName] @@ -275,8 +281,7 @@ aliasUseSiteRangesDisambiguated tcModule importAlias exports hsDecls = nubOrd $ let rdrEnv = tcg_rdr_env (tmrTypechecked tcModule) ImportAlias{aliasModuleName, aliasName} = importAlias aliasLength = fromIntegral (moduleNameLength aliasName) - locatedRdrName :: XRec GhcPs RdrName <- - listify (const True) exports ++ listify (const True) hsDecls + locatedRdrName <- locateRdrNames exports hsDecls rdrName@(Qual qualifier name) <- [unLoc locatedRdrName] guard (qualifier == aliasName) nameGREElement <- pickGREs rdrName $ lookupGlobalRdrEnv rdrEnv name @@ -289,23 +294,56 @@ aliasUseSiteRangesDisambiguated tcModule importAlias exports hsDecls = nubOrd $ & VFS.end .~ (qualifierStart & VFS.character +~ aliasLength) [qualifierRange] -ambiguousAliasErrorMessage :: [ImportAlias] -> T.Text -ambiguousAliasErrorMessage aliases@(alias1 : alias2 : _) = - let aliasCount = T.pack (show (length aliases)) - aliasText = T.pack (moduleNameString (aliasName alias1)) - module1 = T.pack (moduleNameString (aliasModuleName alias1)) - module2 = T.pack (moduleNameString (aliasModuleName alias2)) - quote t = "‘" <> t <> "’" - in ("Alias " <> quote aliasText - <> " is ambiguous (matching " <> aliasCount - <> " imports, including " <> quote module1 <> " and " <> quote module2 - <> "). Try renaming " <> quote aliasText - <> " in one of these import declarations directly.") -ambiguousAliasErrorMessage _ = "" - --------------------------------------------------------------------------------------------------- -- Utility functions +-- | Locate 'RdrName' identifiers in the given export list and declarations. +locateRdrNames :: + Maybe (XRec GhcPs [LIE GhcPs]) -> + [LHsDecl GhcPs] -> + [XRec GhcPs RdrName] +locateRdrNames exports hsDecls = + listify (const True) exports ++ listify (const True) hsDecls + +-- | Check whether the given text is a valid alias. +-- Allows Unicode characters the same way GHC does. +-- REVIEW: If this looks good, we can add it to the existing name-based renaming +-- logic too (and move the CPP stuff to @Compat@). +isValidAlias :: T.Text -> Bool +isValidAlias t = case unP parseIdentifier parseState of + POk _ _ -> True + _ -> False + where + filename = "" + location = mkRealSrcLoc filename 1 1 + buffer = stringToStringBuffer (T.unpack (t <> ".f")) + parseState = initParserState minimalParserOpts buffer location + +minimalParserOpts :: ParserOpts +#if MIN_VERSION_ghc(9,13,0) +minimalParserOpts = mkParserOpts mempty emptyDiagOpts False False False False +#else +minimalParserOpts = mkParserOpts mempty emptyDiagOpts [] False False False False +#endif + +emptyDiagOpts :: GHC.DiagOpts +#if MIN_VERSION_ghc(9,7,0) +emptyDiagOpts = GHC.emptyDiagOpts +#else +emptyDiagOpts = GHC.DiagOpts mempty mempty False False Nothing defaultSDocContext +#endif + +-- >>> isValidAlias (T.pack "M") == True +-- >>> isValidAlias (T.pack "M.F") == True +-- >>> isValidAlias (T.pack "m") == False +-- >>> isValidAlias (T.pack "m.F") == False +-- >>> isValidAlias (T.pack "m.f") == False +-- >>> isValidAlias (T.pack "M.F hiding ()") == False +-- >>> isValidAlias (T.pack "Just . M") == False +-- >>> isValidAlias (T.pack "Dz") == True +-- >>> isValidAlias (T.pack "𝐹") == True +-- >>> isValidAlias (T.pack "𝑓") == False + -- | Check whether a 'CodePointRange' contains a 'CodePointPosition' (inclusive -- start, inclusive end). -- NOTE: The use of inclusive end allows the user to place the cursor at the end @@ -326,6 +364,15 @@ rangeToTextEdit virtualFile newText range = TextEdit <$> VFS.codePointRangeToRange virtualFile range <*> Just newText --- | Returns the length in Unicode code points for a 'ModuleName'. +-- | Return the length in Unicode code points for a 'ModuleName'. moduleNameLength :: ModuleName -> Int moduleNameLength = lengthFS . moduleNameFS + +-- | Return the module name as a 'Text' value. +moduleNameText :: ModuleName -> T.Text +moduleNameText = T.pack . moduleNameString + +-- | Surround the given text with curly single quotation marks (like GHC does in +-- compiler messages). +quote :: T.Text -> T.Text +quote t = "‘" <> t <> "’" diff --git a/plugins/hls-rename-plugin/test/Main.hs b/plugins/hls-rename-plugin/test/Main.hs index 23a5954a41..454f26874c 100644 --- a/plugins/hls-rename-plugin/test/Main.hs +++ b/plugins/hls-rename-plugin/test/Main.hs @@ -125,7 +125,25 @@ renameTests = testGroup "Identifier" , goldenWithRename "Import alias declaration" "ImportAlias" $ \doc -> rename doc (Position 1 14) "G" , goldenWithRename "Import alias at use site" "ImportAlias" $ \doc -> + rename doc (Position 5 6) "G" + , goldenWithRename "Import alias declaration (cursor at end)" "ImportAlias" $ \doc -> + rename doc (Position 1 18) "G" + , goldenWithRename "Import alias at use site (cursor at end)" "ImportAlias" $ \doc -> rename doc (Position 5 10) "G" + , testCase "Import alias declaration (cursor at invalid Unicode position)" $ runRenameSession "" $ do + doc <- openDoc "ImportAlias.hs" "haskell" + expectNoMoreDiagnostics 3 doc "typecheck" + renameErr <- expectRenameError doc (Position 5 7) "G" + liftIO $ do + renameErr ^. L.code @?= InR ErrorCodes_InvalidParams + renameErr ^. L.message @?= "rename: Invalid Params: The cursor position is inside a Unicode surrogate pair." + , testCase "Import alias (invalid new alias)" $ runRenameSession "" $ do + doc <- openDoc "ImportAlias.hs" "haskell" + expectNoMoreDiagnostics 3 doc "typecheck" + renameErr <- expectRenameError doc (Position 5 6) "Just . G" + liftIO $ do + renameErr ^. L.code @?= InR ErrorCodes_InvalidParams + renameErr ^. L.message @?= "rename: Invalid Params: ‘Just . G’ is an invalid import alias." , goldenWithRename "Import alias declaration (shared by unrelated imports)" "ImportAliasShared" $ \doc -> rename doc (Position 3 31) "Maybe" , goldenWithRename "Import alias at use site (shared by unrelated imports)" "ImportAliasShared" $ \doc ->