Skip to content

Commit e0120ee

Browse files
committed
feat(error messages): on name resolution failure, suggest similar names
* Use plurality-agnostic messages for suggestions for simplicity and consistency * Partially addresses #713
1 parent b7b3439 commit e0120ee

File tree

20 files changed

+602
-146
lines changed

20 files changed

+602
-146
lines changed

parser-typechecker/src/Unison/DataDeclaration/Dependencies.hs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,5 +122,6 @@ hashFieldAccessors ppe declName vars declRef dd = do
122122
effectDecls = mempty
123123
},
124124
termsByShortname = mempty,
125+
freeNameToFuzzyTermsByShortName = Map.empty,
125126
topLevelComponents = Map.empty
126127
}

parser-typechecker/src/Unison/FileParsers.hs

Lines changed: 127 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,19 @@ import Control.Monad.State (evalStateT)
1010
import Data.Foldable qualified as Foldable
1111
import Data.List (partition)
1212
import Data.List qualified as List
13+
import Data.List.NonEmpty qualified as NonEmpty
1314
import Data.Map qualified as Map
15+
import Data.Ord (clamp)
1416
import Data.Sequence qualified as Seq
1517
import Data.Set qualified as Set
18+
import Data.Text qualified as Text
1619
import Unison.ABT qualified as ABT
1720
import Unison.Blank qualified as Blank
1821
import Unison.Builtin qualified as Builtin
1922
import Unison.ConstructorReference qualified as ConstructorReference
2023
import Unison.Name (Name)
24+
import Unison.Name qualified as Name
25+
import Unison.NameSegment qualified as NameSegment
2126
import Unison.Names qualified as Names
2227
import Unison.Names.ResolvesTo (ResolvesTo (..))
2328
import Unison.Parser.Ann (Ann)
@@ -28,7 +33,7 @@ import Unison.Referent (Referent)
2833
import Unison.Referent qualified as Referent
2934
import Unison.Result (CompilerBug (..), Note (..), ResultT, pattern Result)
3035
import Unison.Result qualified as Result
31-
import Unison.Syntax.Name qualified as Name (unsafeParseVar)
36+
import Unison.Syntax.Name qualified as Name (toText, unsafeParseText, unsafeParseVar)
3237
import Unison.Syntax.Parser qualified as Parser
3338
import Unison.Term qualified as Term
3439
import Unison.Type qualified as Type
@@ -94,21 +99,50 @@ computeTypecheckingEnvironment shouldUseTndr ambientAbilities typeLookupf uf =
9499
{ ambientAbilities = ambientAbilities,
95100
typeLookup = tl,
96101
termsByShortname = Map.empty,
102+
freeNameToFuzzyTermsByShortName = Map.empty,
97103
topLevelComponents = Map.empty
98104
}
99105
ShouldUseTndr'Yes parsingEnv -> do
100-
let tm = UF.typecheckingTerm uf
101-
resolveName :: Name -> Relation Name (ResolvesTo Referent)
106+
let resolveName :: Name -> Relation Name (ResolvesTo Referent)
102107
resolveName =
103108
Names.resolveNameIncludingNames
104109
(Names.shadowing1 (Names.terms (UF.toNames uf)) (Names.terms (Parser.names parsingEnv)))
105-
(Set.map Name.unsafeParseVar (UF.toTermAndWatchNames uf))
106-
possibleDeps = do
107-
v <- Set.toList (Term.freeVars tm)
108-
let shortname = Name.unsafeParseVar v
109-
(name, ref) <- Rel.toList (resolveName shortname)
110-
[(name, shortname, ref)]
111-
possibleRefs =
110+
localNames
111+
112+
localNames = Set.map Name.unsafeParseVar (UF.toTermAndWatchNames uf)
113+
globalNamesShadowed = Names.shadowing (UF.toNames uf) (Parser.names parsingEnv)
114+
115+
freeNames :: [Name]
116+
freeNames =
117+
Name.unsafeParseVar <$> Set.toList (Term.freeVars $ UF.typecheckingTerm uf)
118+
119+
possibleDepsExact :: [(Name, Name, ResolvesTo Referent)]
120+
possibleDepsExact = do
121+
freeName <- freeNames
122+
(name, ref) <- Rel.toList (resolveName freeName)
123+
[(name, freeName, ref)]
124+
125+
getFreeNameDepsFuzzy :: Name -> [(Name, Name, ResolvesTo Referent)]
126+
getFreeNameDepsFuzzy freeName = do
127+
let wantedTopNFuzzyMatches = 3
128+
-- We use fuzzy matching by edit distance here because it is usually more appropriate
129+
-- than FZF-style fuzzy finding for offering suggestions for typos or other user errors.
130+
let fuzzyMatches =
131+
take wantedTopNFuzzyMatches $
132+
fuzzyFindByEditDistanceRanked globalNamesShadowed localNames freeName
133+
134+
let names = fuzzyMatches ^.. each . _2
135+
let resolvedNames = Rel.toList . resolveName =<< names
136+
let getShortName longname = Name.unsafeParseText (NameSegment.toUnescapedText $ Name.lastSegment longname)
137+
138+
map (\(longname, ref) -> (longname, getShortName longname, ref)) resolvedNames
139+
140+
freeNameDepsFuzzy :: Map Name [(Name, Name, ResolvesTo Referent)]
141+
freeNameDepsFuzzy =
142+
Map.fromList [(freeName, getFreeNameDepsFuzzy freeName) | freeName <- freeNames]
143+
144+
getPossibleRefs :: [(Name, Name, ResolvesTo Referent)] -> Defns (Set TermReference) (Set TypeReference)
145+
getPossibleRefs =
112146
List.foldl'
113147
( \acc -> \case
114148
(_, _, ResolvesToNamespace ref0) ->
@@ -118,30 +152,106 @@ computeTypecheckingEnvironment shouldUseTndr ambientAbilities typeLookupf uf =
118152
(_, _, ResolvesToLocal _) -> acc
119153
)
120154
(Defns Set.empty Set.empty)
121-
possibleDeps
122-
tl <- fmap (UF.declsToTypeLookup uf <>) (typeLookupf (UF.dependencies uf <> possibleRefs))
123-
let termsByShortname :: Map Name [Either Name (Typechecker.NamedReference v Ann)]
124-
termsByShortname =
155+
156+
typeLookup <-
157+
fmap
158+
(UF.declsToTypeLookup uf <>)
159+
( typeLookupf
160+
( UF.dependencies uf
161+
<> getPossibleRefs possibleDepsExact
162+
<> getPossibleRefs (join $ Map.elems freeNameDepsFuzzy)
163+
)
164+
)
165+
166+
let getTermsByShortname :: [(Name, Name, ResolvesTo Referent)] -> Map Name [Either Name (Typechecker.NamedReference v Ann)]
167+
getTermsByShortname =
125168
List.foldl'
126169
( \acc -> \case
127170
(name, shortname, ResolvesToLocal _) -> let v = Left name in Map.upsert (maybe [v] (v :)) shortname acc
128171
(name, shortname, ResolvesToNamespace ref) ->
129-
case TL.typeOfReferent tl ref of
172+
case TL.typeOfReferent typeLookup ref of
130173
Just ty ->
131174
let v = Right (Typechecker.NamedReference name ty (Context.ReplacementRef ref))
132175
in Map.upsert (maybe [v] (v :)) shortname acc
133176
Nothing -> acc
134177
)
135178
Map.empty
136-
possibleDeps
179+
180+
let termsByShortname = getTermsByShortname possibleDepsExact
181+
let freeNameToFuzzyTermsByShortName = Map.mapWithKey (\_ v -> getTermsByShortname v) freeNameDepsFuzzy
182+
137183
pure
138184
Typechecker.Env
139185
{ ambientAbilities,
140-
typeLookup = tl,
186+
typeLookup = typeLookup,
141187
termsByShortname,
188+
freeNameToFuzzyTermsByShortName,
142189
topLevelComponents = Map.empty
143190
}
144191

192+
-- | 'fuzzyFindByEditDistanceRanked' finds matches for the given 'name' within 'names' by edit distance.
193+
--
194+
-- Returns a list of 3-tuples composed of an edit-distance Score, a Name, and a List of term and type references).
195+
--
196+
-- Adapted from Unison.Server.Backend.fuzzyFind
197+
--
198+
-- TODO: Consider moving to Unison.Names
199+
--
200+
-- TODO: Take type similarity into account when ranking matches
201+
fuzzyFindByEditDistanceRanked ::
202+
Names.Names ->
203+
Set Name ->
204+
Name ->
205+
[(Int, Name)]
206+
fuzzyFindByEditDistanceRanked globalNames localNames name =
207+
let query =
208+
(Text.unpack . nameToText) name
209+
210+
-- Use 'nameToTextFromLastNSegments' so edit distance is not biased towards shorter fully-qualified names
211+
-- and the name being queried is only partially qualified.
212+
fzfGlobalNames =
213+
Names.queryEditDistances nameToTextFromLastNSegments query globalNames
214+
fzfLocalNames =
215+
Names.queryEditDistances' nameToTextFromLastNSegments query localNames
216+
fzfNames = fzfGlobalNames ++ fzfLocalNames
217+
218+
-- Keep only matches with a sufficiently low edit-distance score
219+
filterByScore = filter (\(score, _, _) -> score < maxScore)
220+
221+
-- Prefer lower edit distances and then prefer shorter names by segment count
222+
rank (score, name, _) = (score, length $ Name.segments name)
223+
224+
-- Remove dupes based on refs
225+
dedupe =
226+
List.nubOrdOn (\(_, _, refs) -> refs)
227+
228+
dropRef = map (\(x, y, _) -> (x, y))
229+
230+
refine =
231+
dropRef . dedupe . sortOn rank . filterByScore
232+
in refine fzfNames
233+
where
234+
nNameSegments = max 1 $ NonEmpty.length $ Name.segments name
235+
236+
takeLast :: Int -> NonEmpty.NonEmpty a -> [a]
237+
takeLast n xs = NonEmpty.drop (NonEmpty.length xs - n) xs
238+
nameFromLastNSegments =
239+
Name.fromSegments
240+
. NonEmpty.fromList
241+
. takeLast nNameSegments
242+
. Name.segments
243+
244+
-- Convert to lowercase for case-insensitive fuzzy matching
245+
nameToText = Text.toLower . Name.toText
246+
nameToTextFromLastNSegments = nameToText . nameFromLastNSegments
247+
248+
ceilingDiv :: Int -> Int -> Int
249+
ceilingDiv x y = (x + 1) `div` y
250+
-- Expect edit distances (number of typos) to be about half the length of the name being queried
251+
-- But clamp max edit distance to work well with very short names
252+
-- and keep ranking reasonably fast when a verbose name is queried
253+
maxScore = clamp (3, 16) $ Text.length (nameToText name) `ceilingDiv` 2
254+
145255
synthesizeFile ::
146256
forall m v.
147257
(Monad m, Var v) =>

parser-typechecker/src/Unison/PrintError.hs

Lines changed: 66 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ module Unison.PrintError
1414
)
1515
where
1616

17-
import Control.Lens.Tuple (_1, _2, _3)
17+
import Control.Lens.Tuple (_1, _2, _3, _4, _5)
1818
import Data.Foldable qualified as Foldable
1919
import Data.Function (on)
2020
import Data.List (find, intersperse, sortBy)
@@ -628,17 +628,19 @@ renderTypeError e env src = case e of
628628
Type.Var' (TypeVar.Existential {}) -> mempty
629629
_ -> Pr.wrap $ "It should be of type " <> Pr.group (style Type1 (renderType' env expectedType) <> ".")
630630
UnknownTerm {..} ->
631-
let (correct, wrongTypes, wrongNames) =
631+
let (correct, rightNameWrongTypes, wrongNameRightTypes, similarNameRightTypes, similarNameWrongTypes) =
632632
foldr
633633
sep
634634
id
635635
(sortBy (comparing length <> compare `on` (Name.segments . C.suggestionName)) suggestions)
636-
([], [], [])
636+
([], [], [], [], [])
637637
sep s@(C.Suggestion _ _ _ match) r =
638638
case match of
639639
C.Exact -> (_1 %~ (s :)) . r
640-
C.WrongType -> (_2 %~ (s :)) . r
641-
C.WrongName -> (_3 %~ (s :)) . r
640+
C.RightNameWrongType -> (_2 %~ (s :)) . r
641+
C.WrongNameRightType -> (_3 %~ (s :)) . r
642+
C.SimilarNameRightType -> (_4 %~ (s :)) . r
643+
C.SimilarNameWrongType -> (_5 %~ (s :)) . r
642644
undefinedSymbolHelp =
643645
mconcat
644646
[ ( case expectedType of
@@ -668,11 +670,24 @@ renderTypeError e env src = case e of
668670
annotatedAsErrorSite src termSite,
669671
"\n",
670672
case correct of
671-
[] -> case wrongTypes of
672-
[] -> case wrongNames of
673-
[] -> undefinedSymbolHelp
674-
wrongs -> formatWrongs wrongNameText wrongs
675-
wrongs ->
673+
[] -> case rightNameWrongTypes of
674+
[] -> case similarNameRightTypes of
675+
[] ->
676+
-- If available, show any 'WrongNameRightType' or 'SimilarNameWrongType' suggestions
677+
-- Otherwise if no suggestions are available show 'undefinedSymbolHelp'
678+
if null wrongNameRightTypes && null similarNameWrongTypes
679+
then undefinedSymbolHelp
680+
else
681+
mconcat
682+
[ if null similarNameWrongTypes
683+
then ""
684+
else formatWrongs similarNameWrongTypeText similarNameWrongTypes,
685+
if null wrongNameRightTypes
686+
then ""
687+
else formatWrongs wrongNameRightTypeText wrongNameRightTypes
688+
]
689+
similarNameRightTypes -> formatWrongs similarNameRightTypeText similarNameRightTypes
690+
rightNameWrongTypes ->
676691
let helpMeOut =
677692
Pr.wrap
678693
( mconcat
@@ -709,7 +724,7 @@ renderTypeError e env src = case e of
709724
)
710725
]
711726
<> "\n\n"
712-
<> formatWrongs wrongTypeText wrongs
727+
<> formatWrongs rightNameWrongTypeText rightNameWrongTypes
713728
suggs ->
714729
mconcat
715730
[ Pr.wrap
@@ -790,45 +805,46 @@ renderTypeError e env src = case e of
790805
summary note
791806
]
792807
where
793-
wrongTypeText pl =
794-
Pr.paragraphyText
795-
( mconcat
796-
[ "I found ",
797-
pl "a term" "some terms",
798-
" in scope with ",
799-
pl "a " "",
800-
"matching name",
801-
pl "" "s",
802-
" but ",
803-
pl "a " "",
804-
"different type",
805-
pl "" "s",
806-
". ",
807-
"If ",
808-
pl "this" "one of these",
809-
" is what you meant, try using its full name:"
810-
]
811-
)
812-
<> "\n\n"
813-
wrongNameText pl =
814-
Pr.paragraphyText
815-
( mconcat
816-
[ "I found ",
817-
pl "a term" "some terms",
818-
" in scope with ",
819-
pl "a " "",
820-
"matching type",
821-
pl "" "s",
822-
" but ",
823-
pl "a " "",
824-
"different name",
825-
pl "" "s",
826-
". ",
827-
"Maybe you meant ",
828-
pl "this" "one of these",
829-
":\n\n"
830-
]
831-
)
808+
rightNameWrongTypeText _ =
809+
mconcat
810+
[ "I found one or more terms in scope with the ",
811+
Pr.bold "right names ",
812+
"but the ",
813+
Pr.bold "wrong types.",
814+
"\n",
815+
"If you meant to use one of these, try using it with its full name and then adjusting types",
816+
":\n\n"
817+
]
818+
similarNameRightTypeText _ =
819+
mconcat
820+
[ "I found one or more terms in scope with ",
821+
Pr.bold "similar names ",
822+
"and the ",
823+
Pr.bold "right types.",
824+
"\n",
825+
"If you meant to use one of these, try using it instead",
826+
":\n\n"
827+
]
828+
similarNameWrongTypeText _ =
829+
mconcat
830+
[ "I found one or more terms in scope with ",
831+
Pr.bold "similar names ",
832+
"but the ",
833+
Pr.bold "wrong types.",
834+
"\n",
835+
"If you meant to use one of these, try using it instead and then adjusting types",
836+
":\n\n"
837+
]
838+
wrongNameRightTypeText _ =
839+
mconcat
840+
[ "I found one or more terms in scope with the ",
841+
Pr.bold "wrong names ",
842+
"but the ",
843+
Pr.bold "right types.",
844+
"\n",
845+
"If you meant to use one of these, try using it instead",
846+
":\n\n"
847+
]
832848
formatWrongs txt wrongs =
833849
let sz = length wrongs
834850
pl a b = if sz == 1 then a else b

0 commit comments

Comments
 (0)