Skip to content

Commit a86168a

Browse files
authored
Smart Casing in modules and file paths (#4823)
* smart casing in modules and file paths * added testcases for Smart-casing * Reviews
1 parent df47a4c commit a86168a

File tree

4 files changed

+125
-70
lines changed

4 files changed

+125
-70
lines changed

plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/Completion/Completer/FilePath.hs

Lines changed: 77 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ module Ide.Plugin.Cabal.Completion.Completer.FilePath where
55
import Control.Exception (evaluate, try)
66
import Control.Monad (filterM)
77
import Control.Monad.Extra (concatForM, forM)
8+
import Data.Char (isUpper)
9+
import Data.List (find)
810
import qualified Data.Text as T
911
import Distribution.PackageDescription (GenericPackageDescription)
1012
import Ide.Logger
@@ -26,50 +28,39 @@ filePathCompleter recorder cData = do
2628
let prefInfo = cabalPrefixInfo cData
2729
complInfo = pathCompletionInfoFromCabalPrefixInfo "" prefInfo
2830
filePathCompletions <- listFileCompletions recorder complInfo
29-
let scored =
30-
Fuzzy.simpleFilter
31-
Fuzzy.defChunkSize
32-
Fuzzy.defMaxResults
33-
(pathSegment complInfo)
34-
(map T.pack filePathCompletions)
35-
forM
36-
scored
37-
( \compl' -> do
38-
let compl = Fuzzy.original compl'
39-
fullFilePath <- mkFilePathCompletion complInfo compl
40-
pure $ mkCompletionItem (completionRange prefInfo) fullFilePath fullFilePath
41-
)
31+
let query = pathSegment complInfo
32+
originals = map T.pack filePathCompletions
33+
matched = smartCaseFuzzy query originals
34+
35+
forM matched $ \compl -> do
36+
fullFilePath <- mkFilePathCompletion complInfo compl
37+
pure $ mkCompletionItem (completionRange prefInfo) fullFilePath fullFilePath
4238

4339
mainIsCompleter :: (Maybe StanzaName -> GenericPackageDescription -> [FilePath]) -> Completer
4440
mainIsCompleter extractionFunction recorder cData = do
4541
mGPD <- getLatestGPD cData
4642
case mGPD of
4743
Just gpd -> do
48-
let srcDirs = extractionFunction sName gpd
49-
concatForM srcDirs
50-
(\dir' -> do
44+
concatForM srcDirs $ \dir' -> do
5145
let dir = FP.normalise dir'
52-
let pathInfo = pathCompletionInfoFromCabalPrefixInfo dir prefInfo
46+
pathInfo = pathCompletionInfoFromCabalPrefixInfo dir prefInfo
47+
query = pathSegment pathInfo
48+
5349
completions <- listFileCompletions recorder pathInfo
54-
let scored = Fuzzy.simpleFilter
55-
Fuzzy.defChunkSize
56-
Fuzzy.defMaxResults
57-
(pathSegment pathInfo)
58-
(map T.pack completions)
59-
forM
60-
scored
61-
( \compl' -> do
62-
let compl = Fuzzy.original compl'
63-
fullFilePath <- mkFilePathCompletion pathInfo compl
64-
pure $ mkCompletionItem (completionRange prefInfo) fullFilePath fullFilePath
65-
)
66-
)
50+
51+
let originals = map T.pack completions
52+
matched = smartCaseFuzzy query originals
53+
54+
forM matched $ \compl -> do
55+
fullFilePath <- mkFilePathCompletion pathInfo compl
56+
pure $ mkCompletionItem (completionRange prefInfo) fullFilePath fullFilePath
57+
where
58+
sName = stanzaName cData
59+
srcDirs = extractionFunction sName gpd
60+
prefInfo = cabalPrefixInfo cData
6761
Nothing -> do
6862
logWith recorder Debug LogUseWithStaleFastNoResult
6963
pure []
70-
where
71-
sName = stanzaName cData
72-
prefInfo = cabalPrefixInfo cData
7364

7465

7566
-- | Completer to be used when a directory can be completed for the field.
@@ -79,19 +70,13 @@ directoryCompleter recorder cData = do
7970
let prefInfo = cabalPrefixInfo cData
8071
complInfo = pathCompletionInfoFromCabalPrefixInfo "" prefInfo
8172
directoryCompletions <- listDirectoryCompletions recorder complInfo
82-
let scored =
83-
Fuzzy.simpleFilter
84-
Fuzzy.defChunkSize
85-
Fuzzy.defMaxResults
86-
(pathSegment complInfo)
87-
(map T.pack directoryCompletions)
88-
forM
89-
scored
90-
( \compl' -> do
91-
let compl = Fuzzy.original compl'
92-
let fullDirPath = mkPathCompletionDir complInfo compl
93-
pure $ mkCompletionItem (completionRange prefInfo) fullDirPath fullDirPath
94-
)
73+
let query = pathSegment complInfo
74+
originals = map T.pack directoryCompletions
75+
matched = smartCaseFuzzy query originals
76+
77+
forM matched $ \compl -> do
78+
fullFilePath <- mkFilePathCompletion complInfo compl
79+
pure $ mkCompletionItem (completionRange prefInfo) fullFilePath fullFilePath
9580

9681
{- Note [Using correct file path separators]
9782
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -185,3 +170,49 @@ mkFilePathCompletion complInfo completion = do
185170
isFilePath <- doesFileExist $ T.unpack combinedPath
186171
let completedPath = if isFilePath then applyStringNotation (isStringNotationPath complInfo) combinedPath else combinedPath
187172
pure completedPath
173+
174+
-- | Applies smart-case filtering to fuzzy-matched candidates.
175+
-- If the query contains uppercase characters, candidates must
176+
-- contain the query as a case-sensitive substring.
177+
-- If the query is all lowercase, all fuzzy matches are accepted.
178+
smartCaseFuzzy :: T.Text -> [T.Text] -> [T.Text]
179+
smartCaseFuzzy query originals =
180+
let smartSensitive :: Bool
181+
smartSensitive = T.any isUpper query
182+
183+
filtered :: [T.Text]
184+
filtered
185+
| smartSensitive = filter (isCaseSensitiveSubsequence query) originals
186+
| otherwise =originals
187+
188+
pairs :: [(T.Text, T.Text)]
189+
pairs = [ (o, T.toLower o) | o <- filtered ]
190+
191+
matchQuery :: T.Text
192+
matchSpace :: [T.Text]
193+
(matchQuery, matchSpace) =
194+
if smartSensitive
195+
then (query, map fst pairs)
196+
else (T.toLower query, map snd pairs)
197+
198+
scored :: [Fuzzy.Scored T.Text]
199+
scored = Fuzzy.simpleFilter Fuzzy.defChunkSize Fuzzy.defMaxResults matchQuery matchSpace
200+
201+
restore :: T.Text -> T.Text
202+
restore matched =
203+
case find (\(o,l) -> o == matched || l == matched) pairs of
204+
Just (o, _) -> o
205+
Nothing -> matched
206+
in
207+
map (restore . Fuzzy.original) scored
208+
209+
-- | Checks whether the query is a case-sensitive subsequence of the candidate.
210+
-- Characters must appear in order and match exactly (including case).
211+
isCaseSensitiveSubsequence :: T.Text -> T.Text -> Bool
212+
isCaseSensitiveSubsequence q c = go (T.unpack q) (T.unpack c)
213+
where
214+
go [] _ = True
215+
go _ [] = False
216+
go (x:xs) (y:ys)
217+
| x == y = go xs ys
218+
| otherwise = go (x:xs) ys

plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/Completion/Completer/Module.hs

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@ import Ide.Logger (Priority (..),
1414
WithPriority,
1515
logWith)
1616
import Ide.Plugin.Cabal.Completion.Completer.FilePath (listFileCompletions,
17-
mkCompletionDirectory)
17+
mkCompletionDirectory,
18+
smartCaseFuzzy)
1819
import Ide.Plugin.Cabal.Completion.Completer.Paths
1920
import Ide.Plugin.Cabal.Completion.Completer.Simple
2021
import Ide.Plugin.Cabal.Completion.Completer.Types
2122
import Ide.Plugin.Cabal.Completion.Types
2223
import System.Directory (doesFileExist)
2324
import qualified System.FilePath as FP
24-
import qualified Text.Fuzzy.Parallel as Fuzzy
2525

2626
-- | Completer to be used when module paths can be completed for the field.
2727
--
@@ -50,26 +50,25 @@ filePathsForExposedModules
5050
-> CabalPrefixInfo
5151
-> Matcher T.Text
5252
-> IO [T.Text]
53-
filePathsForExposedModules recorder srcDirs prefInfo matcher = do
53+
filePathsForExposedModules recorder srcDirs prefInfo _matcher = do
5454
concatForM
5555
srcDirs
5656
( \dir' -> do
5757
let dir = FP.normalise dir'
5858
pathInfo = pathCompletionInfoFromCabalPrefixInfo dir modPrefInfo
5959
completions <- listFileCompletions recorder pathInfo
6060
validExposedCompletions <- filterM (isValidExposedModulePath pathInfo) completions
61-
let toMatch = pathSegment pathInfo
62-
scored = runMatcher
63-
matcher
64-
toMatch
65-
(map T.pack validExposedCompletions)
66-
forM
67-
scored
68-
( \compl' -> do
69-
let compl = Fuzzy.original compl'
70-
fullFilePath <- mkExposedModulePathCompletion pathInfo $ T.unpack compl
71-
pure fullFilePath
72-
)
61+
62+
let query = pathSegment pathInfo
63+
candidates :: [T.Text]
64+
candidates = map T.pack validExposedCompletions
65+
66+
matched :: [T.Text]
67+
matched = smartCaseFuzzy query candidates
68+
69+
forM matched $ \compl -> do
70+
fullFilePath <- mkExposedModulePathCompletion pathInfo (T.unpack compl)
71+
pure fullFilePath
7372
)
7473
where
7574
prefix =

plugins/hls-cabal-plugin/test/Completer.hs

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -101,16 +101,19 @@ fileCompleterTests =
101101
"File Completer Tests"
102102
[ testCase "Current Directory - no leading ./ by default" $ do
103103
completions <- completeFilePath "" filePathComplTestDir
104-
completions @?== [".hidden", "Content.hs", "dir1/", "dir2/", "textfile.txt", "main-is.cabal"],
104+
completions @?== [".hidden", "Content.hs", "Dir4/", "dir1/", "dir2/", "textfile.txt", "main-is.cabal"],
105105
testCase "Current Directory - alternative writing" $ do
106106
completions <- completeFilePath "./" filePathComplTestDir
107-
completions @?== ["./.hidden", "./Content.hs", "./dir1/", "./dir2/", "./textfile.txt", "./main-is.cabal"],
107+
completions @?== ["./.hidden", "./Content.hs", "./Dir4/", "./dir1/", "./dir2/", "./textfile.txt", "./main-is.cabal"],
108108
testCase "Current Directory - hidden file start" $ do
109109
completions <- completeFilePath "." filePathComplTestDir
110110
completions @?== ["Content.hs", ".hidden", "textfile.txt", "main-is.cabal"],
111111
testCase "Current Directory - incomplete directory path written" $ do
112112
completions <- completeFilePath "di" filePathComplTestDir
113-
completions @?== ["dir1/", "dir2/"],
113+
completions @?== ["dir1/", "dir2/","Dir4/"],
114+
testCase "Current Directory - fuzzy Smart casing" $ do
115+
completions <- completeFilePath "Dr" filePathComplTestDir
116+
completions @?== ["Dir4/"],
114117
testCase "Current Directory - incomplete filepath written" $ do
115118
completions <- completeFilePath "te" filePathComplTestDir
116119
completions @?== ["Content.hs", "textfile.txt"],
@@ -120,6 +123,12 @@ fileCompleterTests =
120123
testCase "Subdirectory - incomplete filepath written" $ do
121124
completions <- completeFilePath "dir2/dir3/MA" filePathComplTestDir
122125
completions @?== ["dir2/dir3/MARKDOWN.md"],
126+
testCase "Subdirectory - lowerCase" $ do
127+
completions <- completeFilePath "dir2/dir3/md" filePathComplTestDir
128+
completions @?== ["dir2/dir3/MARKDOWN.md"],
129+
testCase "Subdirectory - smart casing mismatch" $ do
130+
completions <- completeFilePath "dir2/dir3/Ma" filePathComplTestDir
131+
completions @?== [],
123132
testCase "Nonexistent directory" $ do
124133
completions <- completeFilePath "dir2/dir4/" filePathComplTestDir
125134
completions @?== []
@@ -171,7 +180,7 @@ filePathCompletionContextTests =
171180
queryDirectory = "",
172181
workingDirectory = filePathComplTestDir
173182
}
174-
compls @?== [".hidden", "Content.hs", "dir1/", "dir2/", "textfile.txt", "main-is.cabal"],
183+
compls @?== [".hidden", "Content.hs", "Dir4/", "dir1/", "dir2/", "textfile.txt", "main-is.cabal"],
175184
testCase "In directory" $ do
176185
compls <-
177186
listFileCompletions
@@ -200,13 +209,19 @@ directoryCompleterTests =
200209
"Directory Completer Tests"
201210
[ testCase "Current Directory - no leading ./ by default" $ do
202211
completions <- completeDirectory "" filePathComplTestDir
203-
completions @?== ["dir1/", "dir2/"],
212+
completions @?== ["Dir4/", "dir1/", "dir2/"],
204213
testCase "Current Directory - alternative writing" $ do
205214
completions <- completeDirectory "./" filePathComplTestDir
206-
completions @?== ["./dir1/", "./dir2/"],
215+
completions @?== ["./Dir4/", "./dir1/", "./dir2/"],
207216
testCase "Current Directory - incomplete directory path written" $ do
208-
completions <- completeDirectory "di" filePathComplTestDir
209-
completions @?== ["dir1/", "dir2/"],
217+
completions <- completeDirectory "dr" filePathComplTestDir
218+
completions @?== ["Dir4/", "dir1/", "dir2/"],
219+
testCase "Current Directory - correct smart casing" $ do
220+
completions <- completeDirectory "Dr" filePathComplTestDir
221+
completions @?== ["Dir4/"],
222+
testCase "Current Directory - incorrect smart casing" $ do
223+
completions <- completeDirectory "DI" filePathComplTestDir
224+
completions @?== [],
210225
testCase "Current Directory - incomplete filepath written" $ do
211226
completions <- completeDirectory "te" filePathComplTestDir
212227
completions @?== [],
@@ -315,7 +330,16 @@ exposedModuleCompleterTests =
315330
completions @?== ["File3"],
316331
testCase "Name nothing but not library" $ do
317332
completions <- callModulesCompleter Nothing sourceDirsExtractionTestSuite "3"
318-
completions @?== []
333+
completions @?== [],
334+
testCase "Exposed modules - smart casing rejects mixed-case mismatch" $ do
335+
completions <- callModulesCompleter (Just "benchie") sourceDirsExtractionBenchmark "FL"
336+
completions @?== [],
337+
testCase "Exposed modules - smart casing preserves fuzzy matching" $ do
338+
completions <- callModulesCompleter (Just "benchie") sourceDirsExtractionBenchmark "Fl1"
339+
completions @?== ["File1"],
340+
testCase "Exposed modules - lowercase remains case-insensitive" $ do
341+
completions <- callModulesCompleter (Just "benchie") sourceDirsExtractionBenchmark "fl"
342+
completions @?== ["File1"]
319343
]
320344
where
321345
callModulesCompleter :: Maybe StanzaName -> (Maybe StanzaName -> GenericPackageDescription -> [FilePath]) -> T.Text -> IO [T.Text]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
test markdown file

0 commit comments

Comments
 (0)