Repository: haskell/haskell-language-server
Pull request #4537: Support PackageImports in hiddenPackageSuggestion
Author: login: jian-lin
Created: 2025-04-02T18:20:26Z | Merged: 2025-04-03T20:27:29Z

Fix: #4479

TODO: Probably I need to add more tests.

CC @VeryMilkyJoe @fendor

## Video demo



https://github.com/user-attachments/assets/692f441e-19be-46ac-8197-a74bfe6c481c


## More info

Here is the string being matched.

With `PackageImports`
```
not found: Could not find module ‘Data.List.Split’
Perhaps you meant
  Data.List.Split (needs flag -package-id split-0.2.5)
  Data.List.Split (needs flag -package-id split-0.2.5)
```

Without `PackageImports`
```
not found: Could not load module ‘Data.List.Split’
It is a member of the hidden package ‘split-0.2.5’.
Perhaps you need to add ‘split’ to the build-depends in your .cabal file.
It is a member of the hidden package ‘split-0.2.5’.
Perhaps you need to add ‘split’ to the build-depends in your .cabal file.
```

I also notice that the "not found" error remains even after the dependency is added by the code action (with and without `PackageImports`).  That may be a bug of my lsp client (`eglot` on Emacs) or HLS.  Needs further investigation.


Diff:
diff --git a/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/CabalAdd.hs b/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/CabalAdd.hs
index ed43099998..3b46eec128 100644
--- a/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/CabalAdd.hs
+++ b/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/CabalAdd.hs
@@ -190,6 +190,12 @@ addDependencySuggestCodeAction plId verTxtDocId suggestions haskellFilePath caba
 -- > It is a member of the hidden package ‘split-0.2.5’.
 -- > Perhaps you need to add ‘split’ to the build-depends in your .cabal file."
 --
+-- or this if PackageImports extension is used:
+--
+-- > "Could not find module ‘Data.List.Split’
+-- > Perhaps you meant
+-- >   Data.List.Split (needs flag -package-id split-0.2.5)"
+--
 -- It extracts mentioned package names and version numbers.
 -- In this example, it will be @[("split", "0.2.5")]@
 --
@@ -204,13 +210,18 @@ hiddenPackageSuggestion diag = getMatch (msg =~ regex)
     msg :: T.Text
     msg = _message diag
     regex :: T.Text -- TODO: Support multiple packages suggestion
-    regex = "It is a member of the hidden package [\8216']([a-zA-Z0-9-]*[a-zA-Z0-9])(-([0-9\\.]*))?[\8217']"
+    regex =
+        let regex' = "([a-zA-Z0-9-]*[a-zA-Z0-9])(-([0-9\\.]*))?"
+        in "It is a member of the hidden package [\8216']" <> regex' <> "[\8217']"
+           <> "|"
+           <> "needs flag -package-id " <> regex'
     -- Have to do this matching because `Regex.TDFA` doesn't(?) support
     -- not-capturing groups like (?:message)
     getMatch :: (T.Text, T.Text, T.Text, [T.Text]) -> [(T.Text, T.Text)]
     getMatch (_, _, _, []) = []
-    getMatch (_, _, _, [dependency, _, cleanVersion]) = [(dependency, cleanVersion)]
-    getMatch (_, _, _, _) = error "Impossible pattern matching case"
+    getMatch (_, _, _, [dependency, _, cleanVersion, "", "", ""]) = [(dependency, cleanVersion)]
+    getMatch (_, _, _, ["", "", "", dependency, _, cleanVersion]) = [(dependency, cleanVersion)]
+    getMatch (_, _, _, _) = []
 
 command :: Recorder (WithPriority Log) -> CommandFunction IdeState CabalAddCommandParams
 command recorder state _ params@(CabalAddCommandParams {cabalPath = path, verTxtDocId = verTxtDocId, buildTarget = target, dependency = dep, version = mbVer}) = do
diff --git a/plugins/hls-cabal-plugin/test/CabalAdd.hs b/plugins/hls-cabal-plugin/test/CabalAdd.hs
index 3b36f82bc2..6517c811fe 100644
--- a/plugins/hls-cabal-plugin/test/CabalAdd.hs
+++ b/plugins/hls-cabal-plugin/test/CabalAdd.hs
@@ -33,6 +33,8 @@ cabalAddTests =
         (generateAddDependencyTestSession "cabal-add-lib.cabal" ("src" </> "MyLib.hs") "split" [348])
     , runHaskellTestCaseSession "Code Actions - Can add hidden package to a test" ("cabal-add-testdata" </> "cabal-add-tests")
         (generateAddDependencyTestSession "cabal-add-tests.cabal" ("test" </> "Main.hs") "split" [478])
+    , runHaskellTestCaseSession "Code Actions - Can add hidden package to a test with PackageImports" ("cabal-add-testdata" </> "cabal-add-tests")
+        (generateAddDependencyTestSession "cabal-add-tests.cabal" ("test" </> "MainPackageImports.hs") "split" [731])
     , runHaskellTestCaseSession "Code Actions - Can add hidden package to a benchmark" ("cabal-add-testdata" </> "cabal-add-bench")
         (generateAddDependencyTestSession "cabal-add-bench.cabal" ("bench" </> "Main.hs") "split" [403])
 
@@ -122,6 +124,23 @@ cabalAddTests =
                                    [ ("3d-graphics-examples", T.empty)
                                    , ("3d-graphics-examples", "1.1.6")
                                    ]
+    , testHiddenPackageSuggestions "Check CabalAdd's parser, with version, with PackageImports"
+                                   [ "(needs flag -package-id base-0.1.0.0)"
+                                   , "(needs flag -package-id Blammo-wai-0.11.0)"
+                                   , "(needs flag -package-id BlastHTTP-2.6.4.3)"
+                                   , "(needs flag -package-id CC-delcont-ref-tf-0.0.0.2)"
+                                   , "(needs flag -package-id 3d-graphics-examples-1.1.6)"
+                                   , "(needs flag -package-id AAI-0.1)"
+                                   , "(needs flag -package-id AWin32Console-1.19.1)"
+                                   ]
+                                   [ ("base","0.1.0.0")
+                                   , ("Blammo-wai", "0.11.0")
+                                   , ("BlastHTTP", "2.6.4.3")
+                                   , ("CC-delcont-ref-tf", "0.0.0.2")
+                                   , ("3d-graphics-examples", "1.1.6")
+                                   , ("AAI", "0.1")
+                                   , ("AWin32Console", "1.19.1")
+                                   ]
     ]
  where
     generateAddDependencyTestSession :: FilePath -> FilePath -> T.Text -> [Int] -> Session ()
diff --git a/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-tests/cabal-add-tests.cabal b/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-tests/cabal-add-tests.cabal
index d217f8c4d5..9adc498231 100644
--- a/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-tests/cabal-add-tests.cabal
+++ b/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-tests/cabal-add-tests.cabal
@@ -16,3 +16,11 @@ test-suite cabal-add-tests-test
     hs-source-dirs:   test
     main-is:          Main.hs
     build-depends:    base
+
+test-suite cabal-add-tests-test-package-imports
+    import:           warnings
+    default-language: Haskell2010
+    type:             exitcode-stdio-1.0
+    hs-source-dirs:   test
+    main-is:          MainPackageImports.hs
+    build-depends:    base
diff --git a/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-tests/test/MainPackageImports.hs b/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-tests/test/MainPackageImports.hs
new file mode 100644
index 0000000000..753dd165dd
--- /dev/null
+++ b/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-tests/test/MainPackageImports.hs
@@ -0,0 +1,8 @@
+{-# LANGUAGE PackageImports #-}
+
+module Main (main) where
+
+import "split" Data.List.Split
+
+main :: IO ()
+main = putStrLn "Test suite not yet implemented."


Reviews:
user: login: jian-lin
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T11:31:03Z
user: login: fendor
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T13:52:54Z
user: login: jian-lin
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T16:47:32Z
user: login: jian-lin
body: I think I have addressed all previous comments.  Please take another look.
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T16:53:19Z
user: login: fendor
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T16:57:13Z
user: login: fendor
body: LGTM, thank you for your contribution :heart: 
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-03T16:57:50Z
user: login: fendor
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T17:02:01Z
user: login: jian-lin
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T19:07:59Z
user: login: fendor
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T19:47:33Z
user: login: VeryMilkyJoe
body: LGTM, thank you!
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-03T20:12:31Z

Review comments:
path: plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-tests/test/MainPackageImports.hs
user: login: jian-lin
body: Maybe it is better to find another hidden package and merge the test of `PackageImports` into [the old one](https://github.com/haskell/haskell-language-server/blob/master/plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-tests/test/Main.hs).
created_at: 2025-04-03T11:31:03Z
author_association: COLLABORATOR
diff_hunk: @@ -204,12 +204,16 @@ hiddenPackageSuggestion diag = getMatch (msg =~ regex)
     msg :: T.Text
     msg = _message diag
     regex :: T.Text -- TODO: Support multiple packages suggestion
-    regex = "It is a member of the hidden package [\8216']([a-zA-Z0-9-]*[a-zA-Z0-9])(-([0-9\\.]*))?[\8217']"
+    regex =
+        "It is a member of the hidden package [\8216']([a-zA-Z0-9-]*[a-zA-Z0-9])(-([0-9\\.]*))?[\8217']"
+        <> "|"
+        <> "needs flag -package-id ([a-zA-Z0-9-]*[a-zA-Z0-9])(-([0-9\\.]*))?"
     -- Have to do this matching because `Regex.TDFA` doesn't(?) support
     -- not-capturing groups like (?:message)
     getMatch :: (T.Text, T.Text, T.Text, [T.Text]) -> [(T.Text, T.Text)]
     getMatch (_, _, _, []) = []
-    getMatch (_, _, _, [dependency, _, cleanVersion]) = [(dependency, cleanVersion)]
+    getMatch (_, _, _, [dependency, _, cleanVersion, "", "", ""]) = [(dependency, cleanVersion)]
+    getMatch (_, _, _, ["", "", "", dependency, _, cleanVersion]) = [(dependency, cleanVersion)]
     getMatch (_, _, _, _) = error "Impossible pattern matching case"
path: plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/CabalAdd.hs
user: login: fendor
body: I know you didn't implement this, but I would feel much better if this `error` wasn't there :) Could you fix this while you are here?

I think we can simply ignore this case, e.g. `getMatch (_, _, _, _) = []`.
created_at: 2025-04-03T13:52:05Z
author_association: COLLABORATOR
diff_hunk: @@ -204,12 +204,16 @@ hiddenPackageSuggestion diag = getMatch (msg =~ regex)
     msg :: T.Text
     msg = _message diag
     regex :: T.Text -- TODO: Support multiple packages suggestion
-    regex = "It is a member of the hidden package [\8216']([a-zA-Z0-9-]*[a-zA-Z0-9])(-([0-9\\.]*))?[\8217']"
+    regex =
+        "It is a member of the hidden package [\8216']([a-zA-Z0-9-]*[a-zA-Z0-9])(-([0-9\\.]*))?[\8217']"
+        <> "|"
+        <> "needs flag -package-id ([a-zA-Z0-9-]*[a-zA-Z0-9])(-([0-9\\.]*))?"
path: plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/CabalAdd.hs
user: login: fendor
body: Please also update the docs for this function!
created_at: 2025-04-03T13:52:30Z
author_association: COLLABORATOR
path: plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-tests/test/MainPackageImports.hs
user: login: fendor
body: I think it is fine, but you need to actually run this test as well, right?
created_at: 2025-04-03T13:52:49Z
author_association: COLLABORATOR
path: plugins/hls-cabal-plugin/test/testdata/cabal-add-testdata/cabal-add-tests/test/MainPackageImports.hs
user: login: jian-lin
body: Nice catch! Fixed.
created_at: 2025-04-03T16:47:31Z
author_association: COLLABORATOR
diff_hunk: @@ -122,6 +124,23 @@ cabalAddTests =
                                    [ ("3d-graphics-examples", T.empty)
                                    , ("3d-graphics-examples", "1.1.6")
                                    ]
+    , testHiddenPackageSuggestions "Check CabalAdd's parser, with version, with PackageImports"
+                                   [ "(needs flag -package-id base-0.1.0.0)"
+                                   , "(needs flag -package-id Blammo-wai-0.11.0)"
+                                   , "(needs flag -package-id BlastHTTP-2.6.4.3)"
+                                   , "(needs flag -package-id CC-delcont-ref-tf-0.0.0.2)"
+                                   , "(needs flag -package-id 3d-graphics-examples-1.1.6)"
+                                   , "(needs flag -package-id AAI-0.1)"
+                                   , "(needs flag -package-id AWin32Console-1.19.1)"
+                                   ]
+                                   [ ("base","0.1.0.0")
+                                   , ("Blammo-wai", "0.11.0")
+                                   , ("BlastHTTP", "2.6.4.3")
+                                   , ("CC-delcont-ref-tf", "0.0.0.2")
+                                   , ("3d-graphics-examples", "1.1.6")
+                                   , ("AAI", "0.1")
+                                   , ("AWin32Console", "1.19.1")
+                                   ]
path: plugins/hls-cabal-plugin/test/CabalAdd.hs
user: login: jian-lin
body: I did not add the "without version" test because I am not sure if the "without version" case exists when `PackageImports` is used.  The regex support the "without version" case though.
created_at: 2025-04-03T16:49:57Z
author_association: COLLABORATOR
diff_hunk: @@ -33,6 +33,8 @@ cabalAddTests =
         (generateAddDependencyTestSession "cabal-add-lib.cabal" ("src" </> "MyLib.hs") "split" [348])
     , runHaskellTestCaseSession "Code Actions - Can add hidden package to a test" ("cabal-add-testdata" </> "cabal-add-tests")
         (generateAddDependencyTestSession "cabal-add-tests.cabal" ("test" </> "Main.hs") "split" [478])
+    , runHaskellTestCaseSession "Code Actions - Can add hidden package to a test with PackageImports" ("cabal-add-testdata" </> "cabal-add-tests")
+        (generateAddDependencyTestSession "cabal-add-tests.cabal" ("test" </> "MainPackageImports.hs") "split" [731])
path: plugins/hls-cabal-plugin/test/CabalAdd.hs
user: login: jian-lin
body: Is this single test case enough?  Should I mirror all similar tests?
created_at: 2025-04-03T16:51:37Z
author_association: COLLABORATOR
diff_hunk: @@ -33,6 +33,8 @@ cabalAddTests =
         (generateAddDependencyTestSession "cabal-add-lib.cabal" ("src" </> "MyLib.hs") "split" [348])
     , runHaskellTestCaseSession "Code Actions - Can add hidden package to a test" ("cabal-add-testdata" </> "cabal-add-tests")
         (generateAddDependencyTestSession "cabal-add-tests.cabal" ("test" </> "Main.hs") "split" [478])
+    , runHaskellTestCaseSession "Code Actions - Can add hidden package to a test with PackageImports" ("cabal-add-testdata" </> "cabal-add-tests")
+        (generateAddDependencyTestSession "cabal-add-tests.cabal" ("test" </> "MainPackageImports.hs") "split" [731])
path: plugins/hls-cabal-plugin/test/CabalAdd.hs
user: login: fendor
body: Yeah, sounds reasonable!
created_at: 2025-04-03T16:57:13Z
author_association: COLLABORATOR
diff_hunk: @@ -33,6 +33,8 @@ cabalAddTests =
         (generateAddDependencyTestSession "cabal-add-lib.cabal" ("src" </> "MyLib.hs") "split" [348])
     , runHaskellTestCaseSession "Code Actions - Can add hidden package to a test" ("cabal-add-testdata" </> "cabal-add-tests")
         (generateAddDependencyTestSession "cabal-add-tests.cabal" ("test" </> "Main.hs") "split" [478])
+    , runHaskellTestCaseSession "Code Actions - Can add hidden package to a test with PackageImports" ("cabal-add-testdata" </> "cabal-add-tests")
+        (generateAddDependencyTestSession "cabal-add-tests.cabal" ("test" </> "MainPackageImports.hs") "split" [731])
path: plugins/hls-cabal-plugin/test/CabalAdd.hs
user: login: fendor
body: Sorry, I mean this single test case is enough.
created_at: 2025-04-03T17:02:00Z
author_association: COLLABORATOR
path: plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/CabalAdd.hs
user: login: jian-lin
body: > I also notice that the "not found" error remains even after the dependency is added by the code action (with and without PackageImports). That may be a bug of my lsp client (eglot on Emacs) or HLS. Needs further investigation.

Oh, it is indeed a known HLS [bug][1].  With HLS 2.10.0.0 and vscode 2.5.3, I need to manually restart HLS or saving `hie.yaml` after adding dependencies to `.cabal` file to let HLS find the newly added dependencies.

In addition, there seems to be a bug in my preferred setting: Emacs with `eglot` as a LSP client.  In that setting, only manual restart works and saving `hie.yaml` has no effect.

[1]: https://github.com/haskell/haskell-language-server/issues/1068#issuecomment-2293383826
created_at: 2025-04-03T19:07:58Z
author_association: COLLABORATOR
path: plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/CabalAdd.hs
user: login: fendor
body: Yes, that sounds correct!
created_at: 2025-04-03T19:47:33Z
author_association: COLLABORATOR

PR comments:
user: login: jian-lin
created_at: 2025-04-03T17:10:30Z
body: Thanks for your review!  I had a great experience in my first HLS PR.
author_association: COLLABORATOR
user: login: fendor
created_at: 2025-04-03T19:48:27Z
body: @jian-lin It looks like you need to rebase your PR or merge changes from master into it before we can merge :)
author_association: COLLABORATOR
user: login: jian-lin
created_at: 2025-04-03T19:51:04Z
body: @fendor Rebased.
author_association: COLLABORATOR