From 04654148c661e119759df902ca0a8165b29291b2 Mon Sep 17 00:00:00 2001 From: Enrico Maria De Angelis Date: Thu, 27 Aug 2026 13:28:04 +0200 Subject: [PATCH 1/5] Window-generated file for class-plugin --- plugins/hls-class-plugin/test/Main.hs | 2 ++ plugins/hls-class-plugin/test/testdata/T1W.eq.expected.hs | 6 ++++++ plugins/hls-class-plugin/test/testdata/T1W.hs | 5 +++++ 3 files changed, 13 insertions(+) create mode 100644 plugins/hls-class-plugin/test/testdata/T1W.eq.expected.hs create mode 100644 plugins/hls-class-plugin/test/testdata/T1W.hs diff --git a/plugins/hls-class-plugin/test/Main.hs b/plugins/hls-class-plugin/test/Main.hs index 68d3fa1006..1a32b9db1d 100644 --- a/plugins/hls-class-plugin/test/Main.hs +++ b/plugins/hls-class-plugin/test/Main.hs @@ -48,6 +48,8 @@ codeActionTests = testGroup getActionByTitle "Add placeholders for '=='" , goldenWithClass "Creates a placeholder for '/='" "T1" "ne" $ getActionByTitle "Add placeholders for '/='" + , goldenWithClass "Creates a placeholder for '=='" "T1W" "eq" $ + getActionByTitle "Add placeholders for '=='" , goldenWithClass "Creates a placeholder for both '==' and '/='" "T1" "all" $ getActionByTitle "Add placeholders for all missing methods" , goldenWithClass "Creates a placeholder for 'fmap'" "T2" "fmap" $ diff --git a/plugins/hls-class-plugin/test/testdata/T1W.eq.expected.hs b/plugins/hls-class-plugin/test/testdata/T1W.eq.expected.hs new file mode 100644 index 0000000000..37fb11727d --- /dev/null +++ b/plugins/hls-class-plugin/test/testdata/T1W.eq.expected.hs @@ -0,0 +1,6 @@ +module T1 where + +data X = X + +instance Eq X where + (==) = _ \ No newline at end of file diff --git a/plugins/hls-class-plugin/test/testdata/T1W.hs b/plugins/hls-class-plugin/test/testdata/T1W.hs new file mode 100644 index 0000000000..f6e5a0fc13 --- /dev/null +++ b/plugins/hls-class-plugin/test/testdata/T1W.hs @@ -0,0 +1,5 @@ +module T1 where + +data X = X + +instance Eq X where \ No newline at end of file From c5986f5242b8e8b47cf234f9b5aba52cab8850b8 Mon Sep 17 00:00:00 2001 From: Enrico Maria De Angelis Date: Thu, 27 Aug 2026 16:44:21 +0200 Subject: [PATCH 2/5] fixup! Window-generated file for class-plugin --- plugins/hls-class-plugin/test/Main.hs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/hls-class-plugin/test/Main.hs b/plugins/hls-class-plugin/test/Main.hs index 1a32b9db1d..4b9a3c0676 100644 --- a/plugins/hls-class-plugin/test/Main.hs +++ b/plugins/hls-class-plugin/test/Main.hs @@ -46,10 +46,10 @@ codeActionTests = testGroup ] , goldenWithClass "Creates a placeholder for '=='" "T1" "eq" $ getActionByTitle "Add placeholders for '=='" + , goldenWithClass "Like previous one, but this file has no line terminator" "T1W" "eq" $ + getActionByTitle "Add placeholders for '=='" , goldenWithClass "Creates a placeholder for '/='" "T1" "ne" $ getActionByTitle "Add placeholders for '/='" - , goldenWithClass "Creates a placeholder for '=='" "T1W" "eq" $ - getActionByTitle "Add placeholders for '=='" , goldenWithClass "Creates a placeholder for both '==' and '/='" "T1" "all" $ getActionByTitle "Add placeholders for all missing methods" , goldenWithClass "Creates a placeholder for 'fmap'" "T2" "fmap" $ From ee95906ffda61581f9e0b7303c782508dd48e371 Mon Sep 17 00:00:00 2001 From: Enrico Maria De Angelis Date: Fri, 28 Aug 2026 12:21:42 +0200 Subject: [PATCH 3/5] Add tests for `diffTextEdit` --- hls-plugin-api/src/Ide/PluginUtils.hs | 1 + hls-plugin-api/test/Ide/PluginUtilsTest.hs | 22 ++++++++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/hls-plugin-api/src/Ide/PluginUtils.hs b/hls-plugin-api/src/Ide/PluginUtils.hs index 90e68f85a2..643d615d37 100644 --- a/hls-plugin-api/src/Ide/PluginUtils.hs +++ b/hls-plugin-api/src/Ide/PluginUtils.hs @@ -13,6 +13,7 @@ module Ide.PluginUtils makeDiffTextEditAdditive, diffText, diffText', + diffTextEdit, pluginDescToIdePlugins, idePluginsToPluginDesc, getClientConfig, diff --git a/hls-plugin-api/test/Ide/PluginUtilsTest.hs b/hls-plugin-api/test/Ide/PluginUtilsTest.hs index 01f45b4f63..d64cdbb243 100644 --- a/hls-plugin-api/test/Ide/PluginUtilsTest.hs +++ b/hls-plugin-api/test/Ide/PluginUtilsTest.hs @@ -20,9 +20,9 @@ import Ide.Plugin.Properties (KeyNamePath (..), usePropertyByPath, usePropertyByPathEither) import qualified Ide.Plugin.RangeMap as RangeMap -import Ide.PluginUtils (extractTextInRange, unescape) +import Ide.PluginUtils (extractTextInRange, unescape, diffTextEdit, WithDeletions (IncludeDeletions)) import Language.LSP.Protocol.Types (Position (..), Range (Range), - UInt, isSubrangeOf) + UInt, isSubrangeOf, TextEdit(_newText)) import Test.Tasty import Test.Tasty.Golden (goldenVsStringDiff) import Test.Tasty.HUnit @@ -31,6 +31,7 @@ import Test.Tasty.QuickCheck tests :: TestTree tests = testGroup "PluginUtils" [ unescapeTest + , diffTextEditTest , extractTextInRangeTest , localOption (QuickCheckMaxSize 10000) $ testProperty "RangeMap-List filtering identical" $ @@ -56,6 +57,23 @@ unescapeTest = testGroup "unescape" unescape "\"\\n\\t\"" @?= "\"\\n\\t\"" ] +diffTextEditTest :: TestTree +diffTextEditTest = testGroup "diffTextEdit" + [ testCase "" $ what's'inserted "foo\n" + "foo\nbar\n" + @?= ["bar\n"] + , testCase "" $ what's'inserted "foo\n" + "foo\nbar" + @?= ["bar"] + , testCase "" $ what's'inserted "foo" + "foo\nbar\n" + @?= ["\nbar\n"] + , testCase "" $ what's'inserted "foo" + "foo\nbar" + @?= ["\nbar"] + ] + where what's'inserted from to = _newText <$> diffTextEdit from to IncludeDeletions + extractTextInRangeTest :: TestTree extractTextInRangeTest = testGroup "extractTextInRange" [ testCase "inline range" $ From d5355694c04e8f3b8b9b32a1beacaf0851ed68e8 Mon Sep 17 00:00:00 2001 From: Enrico Maria De Angelis Date: Fri, 28 Aug 2026 18:05:26 +0200 Subject: [PATCH 4/5] Partial solution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I say this solution is partial because: 1. from the perspective of text comparison, it is wrong, as demonstrated by the two tests that fail, 2. from the perspective of our usage of it, which is from call sites¹ that guarantee (or don't they?) that we'll never get those inputs like in the files that cause those failures, it's good enough. --- ¹ The point is that we don't ever use this `diffTextEdit` function on two independent `Text` inputs. Those two inputs are - the content of the source file on which HLS wants to do the change, - the content after the change as computed via GHC's API. As long as GHC (well, and `ghc-exactprint` after it) guarantees to honor the line-ending policy of a file, the scenario of the tests at point 1 above should never materialize. --- hls-plugin-api/src/Ide/PluginUtils.hs | 72 +++++++++++++++++----- hls-plugin-api/test/Ide/PluginUtilsTest.hs | 28 +++++---- 2 files changed, 73 insertions(+), 27 deletions(-) diff --git a/hls-plugin-api/src/Ide/PluginUtils.hs b/hls-plugin-api/src/Ide/PluginUtils.hs index 643d615d37..d131e27ea5 100644 --- a/hls-plugin-api/src/Ide/PluginUtils.hs +++ b/hls-plugin-api/src/Ide/PluginUtils.hs @@ -1,5 +1,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TypeFamilies #-} +{-# LANGUAGE BlockArguments #-} +{-# LANGUAGE LambdaCase #-} module Ide.PluginUtils ( -- * LSP Range manipulation functions @@ -42,23 +44,56 @@ where import Control.Arrow ((&&&)) import Control.Lens (_head, _last, re, (%~), (^.)) -import Data.Algorithm.Diff -import Data.Algorithm.DiffOutput +import Data.Algorithm.Diff ( getGroupedDiff ) +import Data.Algorithm.DiffOutput + ( LineRange(..), DiffOperation(..), diffToLineRanges ) import Data.Char (isPrint, showLitChar) import Data.Functor (void) import qualified Data.Map as M import qualified Data.Text as T import Data.Void (Void) -import Ide.Plugin.Config -import Ide.Plugin.Properties -import Ide.Types +import Ide.Plugin.Config ( Config, PluginConfig(plcConfig) ) +import Ide.Plugin.Properties + ( HasProperty, + KeyNameProxy, + Properties, + ToHsType, + useProperty, + (&) ) +import Ide.Types + ( PluginDescriptor(pluginCommands, pluginId), + IdePlugins(IdePlugins), + PluginId, + PluginCommand(commandId), + getProcessID, + configForPlugin, + mkLspCommand, + mkLspCmdId, + getPid, + installSigUsr1Handler, + PluginMethod(handlesRequest) ) import qualified Language.LSP.Protocol.Lens as L -import Language.LSP.Protocol.Types -import Language.LSP.Server +import Language.LSP.Protocol.Types + ( Range(Range), + Position(Position), + ClientCapabilities(ClientCapabilities), + VersionedTextDocumentIdentifier, + WorkspaceEdit(WorkspaceEdit), + TextEdit(..), + TextDocumentEdit(TextDocumentEdit), + type (|?)(InL), + WorkspaceClientCapabilities(_workspaceEdit), + WorkspaceEditClientCapabilities(WorkspaceEditClientCapabilities), + positionInRange, + _versionedTextDocumentIdentifier, + isSubrangeOf ) +import Language.LSP.Server ( MonadLsp, getConfig ) import System.FilePath (()) import qualified Text.Megaparsec as P import qualified Text.Megaparsec.Char as P import qualified Text.Megaparsec.Char.Lexer as P +import Control.Lens.Setter (over) +import Data.Tuple.Extra (both) -- --------------------------------------------------------------------- @@ -120,18 +155,25 @@ makeDiffTextEditAdditive :: T.Text -> T.Text -> [TextEdit] makeDiffTextEditAdditive f1 f2 = diffTextEdit f1 f2 SkipDeletions diffTextEdit :: T.Text -> T.Text -> WithDeletions -> [TextEdit] -diffTextEdit fText f2Text withDeletions = r +diffTextEdit fText f2Text withDeletions + = newlineFix $ map diffOperationToTextEdit diffOps where - r = map diffOperationToTextEdit diffOps - d = getGroupedDiff (lines $ T.unpack fText) (lines $ T.unpack f2Text) - + (linesL, linesR) = both (lines . T.unpack) (fText, f2Text) + + newlineFix = over _last \case edit | T.last fText /= '\n' + , let lineCount = fromIntegral (length linesL) + insertionLine = edit ^. L.range . L.start . L.line + , insertionLine >= lineCount + -> edit & L.newText %~ rotateRight + where rotateRight xs = T.last xs `T.cons` T.init xs + edit -> edit diffOps = filter (\x -> (withDeletions == IncludeDeletions) || not (isDeletion x)) - (diffToLineRanges d) - - isDeletion (Deletion _ _) = True - isDeletion _ = False + (diffToLineRanges $ getGroupedDiff linesL linesR) + where + isDeletion (Deletion _ _) = True + isDeletion _ = False diffOperationToTextEdit :: DiffOperation LineRange -> TextEdit diffOperationToTextEdit (Change fm to) = TextEdit range nt diff --git a/hls-plugin-api/test/Ide/PluginUtilsTest.hs b/hls-plugin-api/test/Ide/PluginUtilsTest.hs index d64cdbb243..516b5be237 100644 --- a/hls-plugin-api/test/Ide/PluginUtilsTest.hs +++ b/hls-plugin-api/test/Ide/PluginUtilsTest.hs @@ -59,18 +59,22 @@ unescapeTest = testGroup "unescape" diffTextEditTest :: TestTree diffTextEditTest = testGroup "diffTextEdit" - [ testCase "" $ what's'inserted "foo\n" - "foo\nbar\n" - @?= ["bar\n"] - , testCase "" $ what's'inserted "foo\n" - "foo\nbar" - @?= ["bar"] - , testCase "" $ what's'inserted "foo" - "foo\nbar\n" - @?= ["\nbar\n"] - , testCase "" $ what's'inserted "foo" - "foo\nbar" - @?= ["\nbar"] + [ testCase "both newline-terminated (linux-style vs linux-style)" + $ what's'inserted "foo\n" + "foo\nbar\n" + @?= ["bar\n"] + , testCase "neither newline-terminated (win-style vs win-style)" + $ what's'inserted "foo" + "foo\nbar" + @?= ["\nbar"] + , testCase "only left newline-terminated" + $ what's'inserted "foo\n" + "foo\nbar" + @?= ["bar"] + , testCase "only right newline-terminated" + $ what's'inserted "foo" + "foo\nbar\n" + @?= ["\nbar\n"] ] where what's'inserted from to = _newText <$> diffTextEdit from to IncludeDeletions From 14477cbad16eb40ce9aee36bc175aa03b00c66b8 Mon Sep 17 00:00:00 2001 From: Enrico Maria De Angelis Date: Sun, 30 Aug 2026 15:36:34 +0200 Subject: [PATCH 5/5] fixup! Add tests for `diffTextEdit` --- hls-plugin-api/test/Ide/PluginUtilsTest.hs | 63 ++++++++++++++++------ 1 file changed, 46 insertions(+), 17 deletions(-) diff --git a/hls-plugin-api/test/Ide/PluginUtilsTest.hs b/hls-plugin-api/test/Ide/PluginUtilsTest.hs index 516b5be237..19a04ad00f 100644 --- a/hls-plugin-api/test/Ide/PluginUtilsTest.hs +++ b/hls-plugin-api/test/Ide/PluginUtilsTest.hs @@ -59,24 +59,53 @@ unescapeTest = testGroup "unescape" diffTextEditTest :: TestTree diffTextEditTest = testGroup "diffTextEdit" - [ testCase "both newline-terminated (linux-style vs linux-style)" - $ what's'inserted "foo\n" - "foo\nbar\n" - @?= ["bar\n"] - , testCase "neither newline-terminated (win-style vs win-style)" - $ what's'inserted "foo" - "foo\nbar" - @?= ["\nbar"] - , testCase "only left newline-terminated" - $ what's'inserted "foo\n" - "foo\nbar" - @?= ["bar"] - , testCase "only right newline-terminated" - $ what's'inserted "foo" - "foo\nbar\n" - @?= ["\nbar\n"] + [ testGroup "inserting line at EOF" + [ testCase "both newline-terminated (linux-style vs linux-style)" + $ diffTextEditComplete "foo\n" + "foo\nbar\n" + @?= [textEdit "bar\n" + (mkRange 1 0 1 0)] + , testCase "neither newline-terminated (win-style vs win-style)" + $ diffTextEditComplete "foo" + "foo\nbar" + @?= [textEdit "\nbar" + (mkRange 0 3 0 3)] + , testCase "only left newline-terminated" + $ diffTextEditComplete "foo\n" + "foo\nbar" + @?= [textEdit "bar" + (mkRange 1 0 1 0)] + , testCase "only right newline-terminated" + $ diffTextEditComplete "foo" + "foo\nbar\n" + @?= [textEdit "\nbar\n" + (mkRange 0 3 0 3)] + ] + , testGroup "deleting line at EOF" + [ testCase "both newline-terminated (linux-style vs linux-style)" + $ diffTextEditComplete "foo\nbar\n" + "foo\n" + @?= [textEdit "" + (mkRange 1 0 1 4)] + , testCase "neither newline-terminated (win-style vs win-style)" + $ diffTextEditComplete "foo\nbar" + "foo" + @?= [textEdit "" + (mkRange 0 3 1 3)] + , testCase "only left newline-terminated" + $ diffTextEditComplete "foo\nbar" + "foo\n" + @?= [textEdit "" + (mkRange 1 0 1 3)] + , testCase "only right newline-terminated" + $ diffTextEditComplete "foo\nbar\n" + "foo" + @?= [textEdit "" + (mkRange 0 3 1 4)] + ] ] - where what's'inserted from to = _newText <$> diffTextEdit from to IncludeDeletions + where diffTextEditComplete from to = diffTextEdit from to IncludeDeletions + textEdit = flip TextEdit extractTextInRangeTest :: TestTree extractTextInRangeTest = testGroup "extractTextInRange"