Skip to content

Commit 6d8bc5a

Browse files
committed
overloaded record dot plugin intial version (closes #3350)
1 parent 30bcab5 commit 6d8bc5a

18 files changed

+530
-4
lines changed

cabal.project

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ packages:
3636
./plugins/hls-explicit-fixity-plugin
3737
./plugins/hls-explicit-record-fields-plugin
3838
./plugins/hls-refactor-plugin
39+
./plugins/hls-overloaded-record-dot-plugin
3940

4041
-- Standard location for temporary packages needed for particular environments
4142
-- For example it is used in the project gitlab mirror to help in the MAcOS M1 build script

haskell-language-server.cabal

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,11 @@ flag explicitFields
174174
default: True
175175
manual: True
176176

177+
flag overloadedRecordDot
178+
description: Enable overloadedRecordDot plugin
179+
default: True
180+
manual: True
181+
177182
-- formatters
178183

179184
flag floskell
@@ -326,10 +331,15 @@ common explicitFields
326331
build-depends: hls-explicit-record-fields-plugin ^>= 1.0
327332
cpp-options: -DexplicitFields
328333

334+
common overloadedRecordDot
335+
if flag(overloadedRecordDot) && (impl(ghc >= 9.2.0) || flag(ignore-plugins-ghc-bounds))
336+
build-depends: hls-overloaded-record-dot-plugin ^>= 1.0
337+
cpp-options: -Dhls_overloaded_record_dot
338+
329339
-- formatters
330340

331341
common floskell
332-
if flag(floskell) && impl(ghc < 9.5)
342+
if flag(floskell) && impl(ghc < 9.5)
333343
build-depends: hls-floskell-plugin ^>= 1.0
334344
cpp-options: -Dhls_floskell
335345

@@ -387,6 +397,7 @@ library
387397
, ormolu
388398
, stylishHaskell
389399
, refactor
400+
, overloadedRecordDot
390401

391402
exposed-modules:
392403
Ide.Arguments
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Revision history for hls-overloaded-record-dot-plugin
2+
3+
## 1.0.0.0 -- 2023-04-16
4+
5+
* First version.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
Copyright (c) 2023, Nathan Maxson
2+
3+
All rights reserved.
4+
5+
Redistribution and use in source and binary forms, with or without
6+
modification, are permitted provided that the following conditions are met:
7+
8+
* Redistributions of source code must retain the above copyright
9+
notice, this list of conditions and the following disclaimer.
10+
11+
* Redistributions in binary form must reproduce the above
12+
copyright notice, this list of conditions and the following
13+
disclaimer in the documentation and/or other materials provided
14+
with the distribution.
15+
16+
* Neither the name of Nathan Maxson nor the names of other
17+
contributors may be used to endorse or promote products derived
18+
from this software without specific prior written permission.
19+
20+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
21+
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
22+
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
23+
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
24+
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
25+
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
26+
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
27+
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
28+
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
29+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
30+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Explicit Record Fields Plugin
2+
3+
`hls-overloaded-record-dot-plugin` is a plugin to convert record selectors to record dot syntax in GHC 9.2 and above.
4+
5+
6+
## Demo
7+
8+
![Convert Record Selector Demo](example.gif)
9+
10+
11+
## Known limitations
12+
13+
hls-overloaded-record-dot-plugin currently only converts record selectors to the record dot syntax, and will not help you convert your record updaters to overloaded record update syntax.
14+
15+
16+
## Change log
17+
### 1.0.0.0
18+
- Release
Loading
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
cabal-version: 3.0
2+
name: hls-overloaded-record-dot-plugin
3+
version: 1.0.1.0
4+
synopsis: Overloaded record dot plugin for Haskell Language Server
5+
description:
6+
Please see the README on GitHub at <https://github.com/haskell/haskell-language-server#readme>
7+
license: BSD-3-Clause
8+
license-file: LICENSE
9+
author: Nathan Maxson
10+
maintainer: [email protected]
11+
-- copyright:
12+
category: Development
13+
build-type: Simple
14+
extra-doc-files: CHANGELOG.md
15+
extra-source-files:
16+
test/testdata/**/*.hs
17+
18+
source-repository head
19+
type: git
20+
location: https://github.com/haskell/haskell-language-server
21+
22+
common warnings
23+
ghc-options: -Wall
24+
25+
library
26+
import: warnings
27+
exposed-modules: Ide.Plugin.OverloadedRecordDot
28+
-- other-modules:
29+
-- other-extensions:
30+
build-depends:
31+
, base >=4.12 && <5
32+
, ghcide ^>=1.10
33+
, hls-plugin-api ^>=1.6
34+
, lsp
35+
, lens
36+
, hls-graph
37+
, text
38+
, syb
39+
, transformers
40+
, ghc-boot-th
41+
, unordered-containers
42+
, containers
43+
hs-source-dirs: src
44+
default-language: Haskell2010
45+
46+
test-suite tests
47+
import: warnings
48+
default-language: Haskell2010
49+
-- other-modules:
50+
-- other-extensions:
51+
type: exitcode-stdio-1.0
52+
hs-source-dirs: test
53+
main-is: Main.hs
54+
build-depends:
55+
, base
56+
, filepath
57+
, text
58+
, hls-overloaded-record-dot-plugin
59+
, lsp-test
60+
, hls-test-utils
61+
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
{-# LANGUAGE DataKinds #-}
2+
{-# LANGUAGE DeriveGeneric #-}
3+
{-# LANGUAGE FlexibleContexts #-}
4+
{-# LANGUAGE LambdaCase #-}
5+
{-# LANGUAGE OverloadedStrings #-}
6+
{-# LANGUAGE PatternSynonyms #-}
7+
{-# LANGUAGE TypeFamilies #-}
8+
{-# LANGUAGE TypeOperators #-}
9+
{-# LANGUAGE ViewPatterns #-}
10+
11+
module Ide.Plugin.OverloadedRecordDot
12+
( descriptor
13+
, Log
14+
) where
15+
16+
-- based off of Berk Okzuturk's hls-explicit-records-fields-plugin
17+
18+
import Control.Lens ((^.))
19+
import Control.Monad.IO.Class (MonadIO, liftIO)
20+
import Control.Monad.Trans.Except (ExceptT)
21+
import Data.Generics (GenericQ, everything, mkQ)
22+
import qualified Data.HashMap.Strict as HashMap
23+
import Data.Maybe (listToMaybe, maybeToList)
24+
import Data.Text (Text)
25+
import Development.IDE (IdeState, NormalizedFilePath,
26+
Pretty (..), Range,
27+
Recorder (..), Rules,
28+
WithPriority (..),
29+
realSrcSpanToRange)
30+
import Development.IDE.Core.Rules (runAction)
31+
import Development.IDE.Core.RuleTypes (TcModuleResult (..),
32+
TypeCheck (..))
33+
import Development.IDE.Core.Shake (define, use)
34+
import qualified Development.IDE.Core.Shake as Shake
35+
import Development.IDE.GHC.Compat (HsExpr (HsApp, HsPar, HsRecSel, HsVar, OpApp),
36+
Outputable, getLoc, unLoc)
37+
import Development.IDE.GHC.Compat.Core (Extension (OverloadedRecordDot),
38+
GhcPass, LHsExpr, Pass (..),
39+
RealSrcSpan, hs_valds,
40+
pattern RealSrcSpan)
41+
import Development.IDE.GHC.Util (getExtensions,
42+
printOutputable)
43+
import Development.IDE.Graph (RuleResult)
44+
import Development.IDE.Graph.Classes (Hashable, NFData (rnf))
45+
import Development.IDE.Spans.Pragmas (NextPragmaInfo (..),
46+
getFirstPragma,
47+
insertNewPragma)
48+
import Development.IDE.Types.Logger (Priority (..), cmapWithPrio,
49+
logWith, (<+>))
50+
import GHC.Generics (Generic)
51+
import Ide.Plugin.RangeMap (RangeMap)
52+
import qualified Ide.Plugin.RangeMap as RangeMap
53+
import Ide.PluginUtils (getNormalizedFilePath,
54+
handleMaybeM, pluginResponse)
55+
import Ide.Types (PluginDescriptor (..),
56+
PluginId (..),
57+
PluginMethodHandler,
58+
defaultPluginDescriptor,
59+
mkPluginHandler)
60+
import Language.LSP.Types (CodeAction (..),
61+
CodeActionKind (CodeActionRefactorRewrite),
62+
CodeActionParams (..),
63+
Command, List (..),
64+
Method (..), SMethod (..),
65+
TextEdit (..),
66+
WorkspaceEdit (WorkspaceEdit),
67+
fromNormalizedUri,
68+
normalizedFilePathToUri,
69+
type (|?) (InR))
70+
import qualified Language.LSP.Types.Lens as L
71+
72+
data Log
73+
= LogShake Shake.Log
74+
| LogCollectedRecordSelectors [RecordSelectors]
75+
| LogRenderedRecordSelectors [ConvertedRecordSelector]
76+
77+
instance Pretty Log where
78+
pretty = \case
79+
LogShake shakeLog -> pretty shakeLog
80+
LogCollectedRecordSelectors recs -> "Collected record selectors:" <+> pretty recs
81+
LogRenderedRecordSelectors recs -> "Rendered record selectors:" <+> pretty recs
82+
83+
data CollectRecordSelectors = CollectRecordSelectors
84+
deriving (Eq, Show, Generic)
85+
86+
instance Hashable CollectRecordSelectors
87+
instance NFData CollectRecordSelectors
88+
89+
data CollectConvertedRecordSelectorsResult = CCRSR
90+
{ recordInfos :: RangeMap ConvertedRecordSelector
91+
, enabledExtensions :: [GhcExtension]
92+
}
93+
deriving (Generic)
94+
95+
instance NFData CollectConvertedRecordSelectorsResult
96+
97+
instance Show CollectConvertedRecordSelectorsResult where
98+
show _ = "<CollectRecordsResult>"
99+
100+
type instance RuleResult CollectRecordSelectors = CollectConvertedRecordSelectorsResult
101+
102+
-- `Extension` is wrapped so that we can provide an `NFData` instance
103+
-- (without resorting to creating an orphan instance).
104+
newtype GhcExtension = GhcExtension { unExt :: Extension }
105+
106+
instance NFData GhcExtension where
107+
rnf x = x `seq` ()
108+
109+
data RecordSelectors
110+
= RecordSelectors RealSrcSpan (HsExpr (GhcPass 'Renamed))
111+
112+
instance Pretty RecordSelectors where
113+
pretty (RecordSelectors ss e) = pretty (printOutputable ss) <> ":" <+> pretty (printOutputable e)
114+
115+
data ConvertedRecordSelector = ConvertedRecordSelector
116+
{ range :: Range
117+
, convertedDotRecord :: Text
118+
}
119+
deriving (Generic)
120+
121+
instance Pretty ConvertedRecordSelector where
122+
pretty (ConvertedRecordSelector r cdr) = pretty (show r) <> ":" <+> pretty cdr
123+
124+
instance NFData ConvertedRecordSelector
125+
126+
descriptor :: Recorder (WithPriority Log) -> PluginId -> PluginDescriptor IdeState
127+
descriptor recorder plId = (defaultPluginDescriptor plId)
128+
{ pluginHandlers = mkPluginHandler STextDocumentCodeAction codeActionProvider
129+
, pluginRules = collectConvRecSelsRule recorder
130+
}
131+
132+
codeActionProvider :: PluginMethodHandler IdeState 'TextDocumentCodeAction
133+
codeActionProvider ideState pId (CodeActionParams _ _ caDocId caRange _) = pluginResponse $ do
134+
nfp <- getNormalizedFilePath (caDocId ^. L.uri)
135+
pragma <- getFirstPragma pId ideState nfp
136+
CCRSR crsMap (map unExt -> exts) <- collectConvRecSels' ideState nfp
137+
let actions = map (mkCodeAction nfp exts pragma) (RangeMap.filterByRange caRange crsMap)
138+
pure $ List actions
139+
where
140+
mkCodeAction :: NormalizedFilePath -> [Extension] -> NextPragmaInfo -> ConvertedRecordSelector -> Command |? CodeAction
141+
mkCodeAction nfp exts pragma crs = InR CodeAction
142+
{ _title = mkCodeActionTitle exts
143+
, _kind = Just CodeActionRefactorRewrite
144+
, _diagnostics = Nothing
145+
, _isPreferred = Nothing
146+
, _disabled = Nothing
147+
, _edit = Just $ mkWorkspaceEdit nfp edits
148+
, _command = Nothing
149+
, _xdata = Nothing
150+
}
151+
where
152+
edits = mkTextEdit crs : maybeToList pragmaEdit
153+
154+
mkTextEdit :: ConvertedRecordSelector -> TextEdit
155+
mkTextEdit (ConvertedRecordSelector r cdr) = TextEdit r cdr
156+
157+
pragmaEdit :: Maybe TextEdit
158+
pragmaEdit = if OverloadedRecordDot `elem` exts
159+
then Nothing
160+
else Just $ insertNewPragma pragma OverloadedRecordDot
161+
162+
mkWorkspaceEdit :: NormalizedFilePath -> [TextEdit] -> WorkspaceEdit
163+
mkWorkspaceEdit nfp edits = WorkspaceEdit changes Nothing Nothing
164+
where
165+
changes = Just $ HashMap.singleton (fromNormalizedUri (normalizedFilePathToUri nfp)) (List edits)
166+
167+
mkCodeActionTitle :: [Extension] -> Text
168+
mkCodeActionTitle exts =
169+
if OverloadedRecordDot `elem` exts
170+
then title
171+
else title <> " (needs extension: OverloadedRecordDot)"
172+
where
173+
title = "Convert to record dot syntax"
174+
175+
collectConvRecSelsRule :: Recorder (WithPriority Log) -> Rules ()
176+
collectConvRecSelsRule recorder = define (cmapWithPrio LogShake recorder) $ \CollectRecordSelectors nfp ->
177+
use TypeCheck nfp >>= \case
178+
Nothing -> pure ([], Nothing)
179+
Just tmr -> do
180+
let exts = getEnabledExtensions tmr
181+
recSels = getRecordSelectors tmr
182+
logWith recorder Debug (LogCollectedRecordSelectors recSels)
183+
let convertedRecordSelectors = traverse convertRecordSelectors recSels
184+
crsMap = RangeMap.fromList range <$> convertedRecordSelectors
185+
logWith recorder Debug (LogRenderedRecordSelectors (concat convertedRecordSelectors))
186+
pure ([], CCRSR <$> crsMap <*> Just exts)
187+
where
188+
getEnabledExtensions :: TcModuleResult -> [GhcExtension]
189+
getEnabledExtensions = map GhcExtension . getExtensions . tmrParsed
190+
191+
getRecordSelectors :: TcModuleResult -> [RecordSelectors]
192+
getRecordSelectors (tmrRenamed -> (hs_valds -> valBinds,_,_,_)) =
193+
collectRecordSelectors valBinds
194+
195+
convertRecordSelectors :: RecordSelectors -> Maybe ConvertedRecordSelector
196+
convertRecordSelectors (RecordSelectors ss expr) = ConvertedRecordSelector (realSrcSpanToRange ss) <$> convertRecSel expr
197+
198+
convertRecSel :: Outputable (HsExpr (GhcPass c)) => HsExpr (GhcPass c) -> Maybe Text
199+
convertRecSel (HsApp _ s@(unLoc -> HsRecSel _ _) r@(unLoc -> HsVar _ _)) =
200+
Just $ printOutputable r <> "." <> printOutputable s
201+
convertRecSel (HsApp _ s@(unLoc -> HsRecSel _ _) r@(unLoc -> HsPar _ _ _ _)) =
202+
Just $ printOutputable r <> "." <> printOutputable s
203+
convertRecSel ( OpApp _ s@(unLoc -> HsRecSel _ _) _ r) =
204+
Just $ "(" <> printOutputable r <> ")." <> printOutputable s
205+
convertRecSel _ = Nothing
206+
207+
collectRecordSelectors :: GenericQ [RecordSelectors]
208+
collectRecordSelectors = everything (<>) (maybeToList . (Nothing `mkQ` getRecSels))
209+
210+
getRecSels :: LHsExpr (GhcPass 'Renamed) -> Maybe RecordSelectors
211+
-- standard record selection: "field record"
212+
getRecSels e@(unLoc -> HsApp _ (unLoc -> HsRecSel _ _) (unLoc -> HsVar _ _)) =
213+
listToMaybe [ RecordSelectors realSpan' (unLoc e) | RealSrcSpan realSpan' _ <- [ getLoc e ]]
214+
-- Record selection where the field is being applied to a parenthesised expression: "field (record)"
215+
getRecSels e@(unLoc -> HsApp _ (unLoc -> HsRecSel _ _) (unLoc -> HsPar _ _ _ _)) =
216+
listToMaybe [ RecordSelectors realSpan' (unLoc e) | RealSrcSpan realSpan' _ <- [ getLoc e ]]
217+
-- Record selection where the field is being applied with the "$" operator: "field $ record"
218+
getRecSels e@(unLoc -> OpApp _ (unLoc -> HsRecSel _ _) (unLoc -> HsVar _ (unLoc -> d)) _)
219+
| printOutputable d == "$" = listToMaybe [ RecordSelectors realSpan' (unLoc e) | RealSrcSpan realSpan' _ <- [ getLoc e ]]
220+
getRecSels _ = Nothing
221+
222+
collectConvRecSels' :: MonadIO m => IdeState -> NormalizedFilePath -> ExceptT String m CollectConvertedRecordSelectorsResult
223+
collectConvRecSels' ideState =
224+
handleMaybeM "Unable to TypeCheck"
225+
. liftIO
226+
. runAction "overloadedRecordDot.collectRecordSelectors" ideState
227+
. use CollectRecordSelectors
228+

0 commit comments

Comments
 (0)