Skip to content

Import suggestion for missing newtype constructor, all types constructor and indirect overloadedrecorddot fields #4516

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Apr 4, 2025
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ module Development.IDE.Plugin.CodeAction
fillHolePluginDescriptor,
extendImportPluginDescriptor,
-- * For testing
matchRegExMultipleImports
matchRegExMultipleImports,
extractNotInScopeName,
NotInScope(..)
) where

import Control.Applicative ((<|>))
Expand Down Expand Up @@ -1572,13 +1574,34 @@ extractQualifiedModuleNameFromMissingName (T.strip -> missing)
modNameP = fmap snd $ RE.withMatched $ conIDP `sepBy1` RE.sym '.'


-- | A Backward compatible implementation of `lookupOccEnv_AllNameSpaces` for
-- GHC <=9.6
--
-- It looks for a symbol name in all known namespaces, including types,
-- variables, and fieldnames.
--
-- Note that on GHC >= 9.8, the record selectors are not in the `mkVarOrDataOcc`
-- anymore, but are in a custom namespace, see
-- https://gitlab.haskell.org/ghc/ghc/-/wikis/migration/9.8#new-namespace-for-record-fields,
-- hence we need to use this "AllNamespaces" implementation, otherwise we'll
-- miss them.
lookupOccEnvAllNamespaces :: ExportsMap -> T.Text -> [IdentInfo]
#if MIN_VERSION_ghc(9,7,0)
lookupOccEnvAllNamespaces exportsMap name = Set.toList $ mconcat (lookupOccEnv_AllNameSpaces (getExportsMap exportsMap) (mkTypeOcc name))
#else
lookupOccEnvAllNamespaces exportsMap name = maybe [] Set.toList $
lookupOccEnv (getExportsMap exportsMap) (mkVarOrDataOcc name)
<> lookupOccEnv (getExportsMap exportsMap) (mkTypeOcc name) -- look up the modified unknown name in the export map
#endif


constructNewImportSuggestions
:: ExportsMap -> (Maybe T.Text, NotInScope) -> Maybe [T.Text] -> QualifiedImportStyle -> [ImportSuggestion]
constructNewImportSuggestions exportsMap (qual, thingMissing) notTheseModules qis = nubOrdBy simpleCompareImportSuggestion
[ suggestion
| Just name <- [T.stripPrefix (maybe "" (<> ".") qual) $ notInScope thingMissing] -- strip away qualified module names from the unknown name
, identInfo <- maybe [] Set.toList $ lookupOccEnv (getExportsMap exportsMap) (mkVarOrDataOcc name)
<> lookupOccEnv (getExportsMap exportsMap) (mkTypeOcc name) -- look up the modified unknown name in the export map

, identInfo <- lookupOccEnvAllNamespaces exportsMap name -- look up the modified unknown name in the export map
, canUseIdent thingMissing identInfo -- check if the identifier information retrieved can be used
, moduleNameText identInfo `notElem` fromMaybe [] notTheseModules -- check if the module of the identifier is allowed
, suggestion <- renderNewImport identInfo -- creates a list of import suggestions for the retrieved identifier information
Expand Down Expand Up @@ -1825,7 +1848,7 @@ data NotInScope
= NotInScopeDataConstructor T.Text
| NotInScopeTypeConstructorOrClass T.Text
| NotInScopeThing T.Text
deriving Show
deriving (Show, Eq)

notInScope :: NotInScope -> T.Text
notInScope (NotInScopeDataConstructor t) = t
Expand All @@ -1840,6 +1863,38 @@ extractNotInScopeName x
= Just $ NotInScopeDataConstructor name
| Just [name] <- matchRegexUnifySpaces x "ot in scope: type constructor or class [^‘]*‘([^’]*)’"
= Just $ NotInScopeTypeConstructorOrClass name
| Just [name] <- matchRegexUnifySpaces x "The data constructors of ‘([^ ]+)’ are not all in scope"
= Just $ NotInScopeDataConstructor name
| Just [name] <- matchRegexUnifySpaces x "of newtype ‘([^’]*)’ is not in scope"
= Just $ NotInScopeThing name
-- Match for HasField "foo" Bar String in the context where, e.g. x.foo is
-- used, and x :: Bar.
--
-- This usually mean that the field is not in scope and the correct fix is to
-- import (Bar(foo)) or (Bar(..)).
--
-- However, it is more reliable to match for the type name instead of the field
-- name, and most of the time you'll want to import the complete type with all
-- their fields instead of the specific field.
--
-- The regex is convoluted because it accounts for:
--
-- - Qualified (or not) `HasField`
-- - The type bar is always qualified. If it is unqualified, it means that the
-- parent module is already imported, and in this context it uses an hint
-- already available in the GHC error message. However this regex accounts for
-- qualified or not, it does not cost much and should be more robust if the
-- hint changes in the future
-- - Next regex will account for polymorphic types, which appears as `HasField
-- "foo" (Bar Int)...`, e.g. see the parenthesis
| Just [_module, name] <- matchRegexUnifySpaces x "No instance for [‘(].*HasField \"[^\"]+\" ([^ (.]+\\.)*([^ (.]+).*[’)]"
= Just $ NotInScopeThing name
| Just [_module, name] <- matchRegexUnifySpaces x "No instance for [‘(].*HasField \"[^\"]+\" \\(([^ .]+\\.)*([^ .]+)[^)]*\\).*[’)]"
= Just $ NotInScopeThing name
-- The order of the "Not in scope" is important, for example, some of the
-- matcher may catch the "record" value instead of the value later.
| Just [name] <- matchRegexUnifySpaces x "Not in scope: record field ‘([^’]*)’"
= Just $ NotInScopeThing name
| Just [name] <- matchRegexUnifySpaces x "ot in scope: \\(([^‘ ]+)\\)"
= Just $ NotInScopeThing name
| Just [name] <- matchRegexUnifySpaces x "ot in scope: ([^‘ ]+)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ matchRegex message regex = case message =~~ regex of
Nothing -> Nothing

-- | 'matchRegex' combined with 'unifySpaces'
--
-- >>> matchRegexUnifySpaces "hello I'm a cow" "he(ll)o"
-- Just ["ll"]
matchRegexUnifySpaces :: T.Text -> T.Text -> Maybe [T.Text]
matchRegexUnifySpaces message = matchRegex (unifySpaces message)

Expand Down
151 changes: 150 additions & 1 deletion plugins/hls-refactor-plugin/test/Main.hs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import Development.IDE.Plugin.CodeAction (matchRegExMultipleImp
import Test.Hls

import qualified Development.IDE.GHC.ExactPrint
import Development.IDE.Plugin.CodeAction (NotInScope (..))
import qualified Development.IDE.Plugin.CodeAction as Refactor
import qualified Test.AddArgument

Expand All @@ -68,6 +69,7 @@ tests =
, codeActionTests
, codeActionHelperFunctionTests
, completionTests
, extractNotInScopeNameTests
]

initializeTests :: TestTree
Expand Down Expand Up @@ -300,6 +302,8 @@ codeActionTests = testGroup "code actions"
, suggestImportClassMethodTests
, suggestImportTests
, suggestAddRecordFieldImportTests
, suggestAddCoerceMissingConstructorImportTests
, suggestAddGenericMissingConstructorImportTests
, suggestHideShadowTests
, fixConstructorImportTests
, fixModuleImportTypoTests
Expand All @@ -316,6 +320,7 @@ codeActionTests = testGroup "code actions"
, addImplicitParamsConstraintTests
, removeExportTests
, Test.AddArgument.tests
, suggestAddRecordFieldUpdateImportTests
]

insertImportTests :: TestTree
Expand Down Expand Up @@ -1849,8 +1854,14 @@ suggestImportTests = testGroup "suggest import actions"
suggestAddRecordFieldImportTests :: TestTree
suggestAddRecordFieldImportTests = testGroup "suggest imports of record fields when using OverloadedRecordDot"
[ testGroup "The field is suggested when an instance resolution failure occurs"
[ ignoreForGhcVersions [GHC94, GHC96] "Extension not present <9.2, and the assist is derived from the help message in >=9.4" theTest
([ ignoreForGhcVersions [GHC94, GHC96] "Extension not present <9.2, and the assist is derived from the help message in >=9.4" theTest
]
++ [
theTestIndirect qualifiedGhcRecords polymorphicType
|
qualifiedGhcRecords <- [False, True]
, polymorphicType <- [False, True]
])
]
where
theTest = testSessionWithExtraFiles "hover" def $ \dir -> do
Expand All @@ -1871,6 +1882,144 @@ suggestAddRecordFieldImportTests = testGroup "suggest imports of record fields w
contentAfterAction <- documentContents doc
liftIO $ after @=? contentAfterAction

theTestIndirect qualifiedGhcRecords polymorphicType = testGroup
((if qualifiedGhcRecords then "qualified-" else "unqualified-")
<> ("HasField " :: String)
<>
(if polymorphicType then "polymorphic-" else "monomorphic-")
<> "type ")
. (\x -> [x]) $ testSessionWithExtraFiles "hover" def $ \dir -> do
-- Hopefully enable project indexing?
configureCheckProject True

let
before = T.unlines ["{-# LANGUAGE OverloadedRecordDot #-}", "module A where", if qualifiedGhcRecords then "" else "import GHC.Records", "import C (bar)", "spam = bar.foo"]
after = T.unlines ["{-# LANGUAGE OverloadedRecordDot #-}", "module A where", if qualifiedGhcRecords then "" else "import GHC.Records", "import C (bar)", "import B (Foo(..))", "spam = bar.foo"]
cradle = "cradle: {direct: {arguments: [-hide-all-packages, -package, base, -package, text, -package-env, -, A, B, C]}}"
liftIO $ writeFileUTF8 (dir </> "hie.yaml") cradle
liftIO $ writeFileUTF8 (dir </> "B.hs") $ unlines ["module B where", if polymorphicType then "data Foo x = Foo { foo :: x }" else "data Foo = Foo { foo :: Int }"]
liftIO $ writeFileUTF8 (dir </> "C.hs") $ unlines ["module C where", "import B", "bar = Foo 10" ]
doc <- createDoc "Test.hs" "haskell" before
waitForProgressDone
_ <- waitForDiagnostics
let defLine = 4
range = Range (Position defLine 0) (Position defLine maxBound)
actions <- getCodeActions doc range
action <- pickActionWithTitle "import B (Foo(..))" actions
executeCodeAction action
contentAfterAction <- documentContents doc
liftIO $ after @=? contentAfterAction

suggestAddRecordFieldUpdateImportTests :: TestTree
suggestAddRecordFieldUpdateImportTests = testGroup "suggest imports of record fields in update"
[ testGroup "implicit import of type" [theTest ] ]
where
theTest = testSessionWithExtraFiles "hover" def $ \dir -> do
configureCheckProject True

let
before = T.unlines ["module C where", "import B", "biz = bar { foo = 100 }"]
after = T.unlines ["module C where", "import B", "import A (Foo(..))", "biz = bar { foo = 100 }"]
cradle = "cradle: {direct: {arguments: [-hide-all-packages, -package, base, -package, text, -package-env, -, A, B, C]}}"
liftIO $ writeFileUTF8 (dir </> "hie.yaml") cradle
liftIO $ writeFileUTF8 (dir </> "A.hs") $ unlines ["module A where", "data Foo = Foo { foo :: Int }"]
liftIO $ writeFileUTF8 (dir </> "B.hs") $ unlines ["module B where", "import A", "bar = Foo 10" ]
doc <- createDoc "Test.hs" "haskell" before
waitForProgressDone
diags <- waitForDiagnostics
liftIO $ print diags
let defLine = 2
range = Range (Position defLine 0) (Position defLine maxBound)
actions <- getCodeActions doc range
liftIO $ print actions
action <- pickActionWithTitle "import A (Foo(..))" actions
executeCodeAction action
contentAfterAction <- documentContents doc
liftIO $ after @=? contentAfterAction

extractNotInScopeNameTests :: TestTree
extractNotInScopeNameTests =
testGroup "extractNotInScopeName" [
testGroup "record field" [
testCase ">=ghc 910" $ Refactor.extractNotInScopeName "Not in scope: ‘foo’" @=? Just (NotInScopeThing "foo"),
testCase "<ghc 910" $ Refactor.extractNotInScopeName "Not in scope: record field ‘foo’" @=? Just (NotInScopeThing "foo")
],
testGroup "HasField" [
testGroup "unqualified" [
testGroup "nice ticks" [
testCase "Simple type" $ Refactor.extractNotInScopeName "No instance for ‘HasField \"baz\" Cheval Bool’" @=? Just (NotInScopeThing "Cheval"),
testCase "Parametric type" $ Refactor.extractNotInScopeName "No instance for ‘HasField \"bar\" (Hibou Int) a0’" @=? Just (NotInScopeThing "Hibou"),
testCase "Parametric type" $ Refactor.extractNotInScopeName "No instance for ‘HasField \"foo\" (Tortue Int) Int’" @=? Just (NotInScopeThing "Tortue")
],
testGroup "parenthesis" [
testCase "Simple type" $ Refactor.extractNotInScopeName "No instance for ‘HasField \"blup\" Calamar Bool’" @=? Just (NotInScopeThing "Calamar"),
testCase "Parametric type" $ Refactor.extractNotInScopeName "No instance for ‘HasField \"biz\" (Ornithorink Int) a0’" @=? Just (NotInScopeThing "Ornithorink"),
testCase "Parametric type" $ Refactor.extractNotInScopeName "No instance for ‘HasField \"blork\" (Salamandre Int) Int’" @=? Just (NotInScopeThing "Salamandre")
]
],
testGroup "qualified" [
testGroup "nice ticks" [
testCase "Simple type" $ Refactor.extractNotInScopeName "No instance for ‘GHC.HasField \"baz\" Cheval Bool’" @=? Just (NotInScopeThing "Cheval"),
testCase "Parametric type" $ Refactor.extractNotInScopeName "No instance for ‘Record.HasField \"bar\" (Hibou Int) a0’" @=? Just (NotInScopeThing "Hibou"),
testCase "Parametric type" $ Refactor.extractNotInScopeName "No instance for ‘Youpi.HasField \"foo\" (Tortue Int) Int’" @=? Just (NotInScopeThing "Tortue")
],
testGroup "parenthesis" [
testCase "Simple type" $ Refactor.extractNotInScopeName "No instance for ‘GHC.Tortue.HasField \"blup\" Calamar Bool’" @=? Just (NotInScopeThing "Calamar"),
testCase "Parametric type" $ Refactor.extractNotInScopeName "No instance for ‘Youpi.Salamandre.HasField \"biz\" (Ornithorink Int) a0’" @=? Just (NotInScopeThing "Ornithorink"),
testCase "Parametric type" $ Refactor.extractNotInScopeName "No instance for ‘Foo.Bar.HasField \"blork\" (Salamandre Int) Int’" @=? Just (NotInScopeThing "Salamandre")
]
]
]
]
suggestAddCoerceMissingConstructorImportTests :: TestTree
suggestAddCoerceMissingConstructorImportTests = testGroup "suggest imports of newtype constructor when using coerce"
[ testGroup "The newtype constructor is suggested when a matching representation error"
[ theTest
]
]
where
theTest = testSessionWithExtraFiles "hover" def $ \dir -> do
configureCheckProject False
let before = T.unlines ["module A where", "import Data.Coerce (coerce)", "import Data.Semigroup (Sum)", "bar = coerce (10 :: Int) :: Sum Int"]
after = T.unlines ["module A where", "import Data.Coerce (coerce)", "import Data.Semigroup (Sum)", "import Data.Semigroup (Sum(..))", "bar = coerce (10 :: Int) :: Sum Int"]
cradle = "cradle: {direct: {arguments: [-hide-all-packages, -package, base, -package, text, -package-env, -, A]}}"
liftIO $ writeFileUTF8 (dir </> "hie.yaml") cradle
doc <- createDoc "Test.hs" "haskell" before
waitForProgressDone
_ <- waitForDiagnostics
let defLine = 3
range = Range (Position defLine 0) (Position defLine maxBound)
actions <- getCodeActions doc range
action <- pickActionWithTitle "import Data.Semigroup (Sum(..))" actions
executeCodeAction action
contentAfterAction <- documentContents doc
liftIO $ after @=? contentAfterAction

suggestAddGenericMissingConstructorImportTests :: TestTree
suggestAddGenericMissingConstructorImportTests = testGroup "suggest imports of type constructors when using generic deriving"
[ testGroup "The type constructors are suggested when not in scope"
[ theTest
]
]
where
theTest = testSessionWithExtraFiles "hover" def $ \dir -> do
configureCheckProject False
let
before = T.unlines ["module A where", "import GHC.Generics", "import Data.Semigroup (Sum)", "deriving instance Generic (Sum Int)"]
after = T.unlines ["module A where", "import GHC.Generics", "import Data.Semigroup (Sum)", "import Data.Semigroup (Sum(..))", "deriving instance Generic (Sum Int)"]
cradle = "cradle: {direct: {arguments: [-hide-all-packages, -package, base, -package, text, -package-env, -, A]}}"
liftIO $ writeFileUTF8 (dir </> "hie.yaml") cradle
doc <- createDoc "Test.hs" "haskell" before
waitForProgressDone
_ <- waitForDiagnostics
let defLine = 3
range = Range (Position defLine 0) (Position defLine maxBound)
actions <- getCodeActions doc range
action <- pickActionWithTitle "import Data.Semigroup (Sum(..))" actions
executeCodeAction action
contentAfterAction <- documentContents doc
liftIO $ after @=? contentAfterAction


suggestImportDisambiguationTests :: TestTree
suggestImportDisambiguationTests = testGroup "suggest import disambiguation actions"
Expand Down
Loading