-
Notifications
You must be signed in to change notification settings - Fork 1.1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Make code completions and import suggestions work correctly for extensions with leading using clauses #11187
Make code completions and import suggestions work correctly for extensions with leading using clauses #11187
Conversation
…sions with leading using clauses
@@ -308,40 +308,140 @@ class CompletionTest { | |||
|
|||
@Test def completeExtensionMethodWithTypeParameter: Unit = { | |||
code"""object Foo | |||
|extension [A](foo: Foo.type) def xxxx: Int = 1 | |||
|extension (foo: Foo.type) def xxxx[A]: Int = 1 | |||
|object Main { Foo.xx${m1} }""".withSource | |||
.completion(m1, Set(("xxxx", Method, "[A] => Int"))) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it an accidenal change? The same question for the change below.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is intentional. This is actually a new test case. The old one got copied below and renamed to completeExtensionMethodFromExtensionWithTypeParameter
because now we need to distinguish between the two
ref.symbol.is(ExtensionMethod) | ||
&& !receiver.isBottomType | ||
&& isApplicableMethodRef(ref, receiver :: Nil, WildcardType) | ||
private def tryApplyingReceiverToTruncatedExtMethod(methodSym: TermSymbol, receiver: Tree)(using Context): scala.util.Try[Tree] = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Usage of TermSymbol
cannot handle prefix types, it's better to use TermRef
.
The name of the method is long, but not informative. I'd suggest make it shorter and add documentation to make its semantics clear.
case meth: MethodType => meth.newLikeThis(meth.paramNames, meth.paramInfos, defn.AnyType) | ||
|
||
val truncatedSym = methodSym.copy(owner = defn.RootPackage, name = Names.termName(""), info = truncateExtension(methodSym.info)) | ||
val truncatedRef = ref(truncatedSym).withSpan(Span(0, 0)) // Fake span needed to make this work in REPL |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe use the position from receiver
?
@liufengyun reworks done. |
0c0c126
to
17a8b8f
Compare
Nice job, I'll have a look today 👍 |
def truncateExtension(tp: Type)(using Context): Type = tp match | ||
case poly: PolyType => poly.newLikeThis(poly.paramNames, poly.paramInfos, truncateExtension(poly.resType)) | ||
case meth: MethodType if meth.isContextualMethod => meth.newLikeThis(meth.paramNames, meth.paramInfos, truncateExtension(meth.resType)) | ||
case meth: MethodType => meth.newLikeThis(meth.paramNames, meth.paramInfos, defn.AnyType) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nitpick: the lines are too long here (and in some other places), might be good to write the body of case in a new line.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
|
||
applyWithoutPostreceiverImplicits(methodRef, receiver) | ||
.toOption | ||
.map(tree => replaceCallee(tree, ref(methodRef))) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd suggest merge tryApplyingReceiver
and applyWithoutPostreceiverImplicits
, maybe name it tryApplyExtensionMethod
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I kept them separate for performance as applyWithoutPostreceiverImplicits
is also reused when searching for potential import suggestions and there we are only interested in whether it was a success, not in the transformed result. Also IMHO with tryApplyExtensionMethod
it would not be less clear that the arguments following the receiver are not passed to the method
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I kept them separate for performance as applyWithoutPostreceiverImplicits is also reused when searching for potential import suggestions and there we are only interested in whether it was a success, not in the transformed result.
I see, it makes sense to keep them separate, as long as the former is private
, as applyWithoutPostreceiverImplicits
is highly coupled with tryApplyingReceiver
.
with
tryApplyExtensionMethod
it would not be less clear that the arguments following the receiver are not passed to the method
I don't see it's a concern, as the documentation serves as a specification of the behavior. The long name yyyWithoutXXX
or yyyNoXXX
only makes sense when we have a version named yyy
. The long name does not help here as (1) there is no ambiguity, (2) it's harder for the reader, (3) it's still unclear what it means, as some implicits will be resolved.
On the other hand, tryApplyingExtensionMethod
is more informative than tryApplyingReceiver
. So I suggest:
applyWithoutPostreceiverImplicits
->tryApplyingExtensionMethod1
, the return type toOption[Tree]
tryApplyingReceiver
->tryApplyingExtensionMethod
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we then move .toOption
to the body of tryApplyingExtensionMethod1
then the performance optimization is lost. If we don't care about it (don't we?) then keeping the method split doesn't make sense
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think this micro-optimization matters, having a more clear return type is more important. The current result type Try[Tree]
provides more information than necessary, and can be safely replaced with Option[Tree]
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
BTW, the code can use try/catch
to return Option[Tree]
without the overhead of .toOption
.
|
||
def isApplicableExtensionMethod(methodRef: TermRef, receiverType: Type)(using Context): Boolean = | ||
methodRef.symbol.is(ExtensionMethod) && !receiverType.isBottomType && | ||
applyWithoutPostreceiverImplicits(methodRef, Typed(EmptyTree, TypeTree(receiverType))).isSuccess |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's wrong with isApplicableMethodRef
in the original code?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
isApplicableMethodRef
checks only direct application of arguments without any adjustments like inserting implicits before the supplied arguments list
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the clarification. Then maybe use Typyer.extMethodApply
directly on the extension method without any truncating?
Edit: yes, user may provide implicits explicitly.
17a8b8f
to
ba382b9
Compare
val types = imp.site.member(name.toTypeName).alternatives.map(denot => nameInScope.toTypeName -> denot) | ||
CompletionScope.fromNamed(terms ++ types) | ||
|
||
val givenImports = CompletionScope.fromNamed(imp.importedImplicits.map(x => (x.implicitName, x.underlyingRef.denot.asSingleDenotation))) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nitpick: this line is too long. Introduce an intermediate variable.
case name: TermName if name.startsWith(matchingNamePrefix) => Some((denot, name)) | ||
case _ => None | ||
|
||
types.flatMap{ tpe => |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
types.flatMap{ tpe => | |
types.flatMap { tpe => |
case (termRef, termName) => | ||
if termRef.symbol.is(ExtensionMethod) && !qual.tpe.isBottomType then | ||
val applied = tryApplyingReceiver(termRef) | ||
applied.map{ denot => |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
applied.map{ denot => | |
applied.map { denot => |
} | ||
|
||
private def fromNamed(namedDenots: Seq[(Name, SingleDenotation)])(using Context): CompletionScope = { | ||
val mappings = namedDenots.filter((name, den) => include(den.symbol, name)).toList.groupBy(_._1).map( (name, namedDens) => name.stripModuleClassSuffix -> namedDens.map(_._2)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nitpick: this line is too long.
|
||
extension (scope: CompletionScope.type) | ||
private def from(denots: Seq[SingleDenotation])(using Context): CompletionScope = { | ||
val mappings = denots.filter(den => include(den.symbol, den.name)).toList.groupBy(_.name).map( (name, denots) => name.stripModuleClassSuffix -> denots) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nitpick: this line is too long.
.collect { | ||
case (name, (_, denots) :: Nil) => name -> denots | ||
} | ||
CompletionScope(mappings) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add documentation for the intended semantics of the behavior.
@@ -386,6 +366,28 @@ object Completion { | |||
!name.isConstructorName && name.toTermName.info.kind == SimpleNameKind | |||
def isStable = true | |||
} | |||
|
|||
extension (scope: CompletionScope) | |||
private def withDenots(denotations: Seq[SingleDenotation], name: Name)(using Context): CompletionScope = { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nitpick: I'd reverse the order of the two parameters.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This method is not used anywhere. I just forgot to remove it
} | ||
|
||
extension (scope: CompletionScope.type) | ||
private def from(denots: Seq[SingleDenotation])(using Context): CompletionScope = { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It constructs an instance of CompletionScope
? Maybe rename to apply
for easy understanding.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Then it would make sense to also rename fromNamed
but they can't have the same name because of type erasure
val empty = CompletionScope() | ||
} | ||
|
||
private case class CompletionScope(nameToDenots: Map[Name, List[SingleDenotation]] = Map.empty) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add documentation for the class CompletionScope
.
Just curious, does it make sense to merge CompletionScope
and CompletionBuffer
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CompletionScope
was supposed to be a lightweight wrapper over Map
taking care of filtering out not matching completions and resolving conflicts between different denotations with the same name.
CompletionBuffer
holds much more logic
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What about keeps a mutable map inside CompletionBuffer
, which is closer to what the name buffer
suggests.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To clarify, I'm not proposing the change. I'm just trying to understand the design considerations here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The name comes from the times when it indeed was mutable but some corner cases seemed too hard to fix with mutability. Any idea for a better name?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not familiar with this part of code, so I'm asking questions to learn more about it.
I think the name is OK, I'm more interested in learning the relationships and responsibilities of the classes.
Creating a separate class with only one field looks dubious. However, in this case it may be justified if there is some special semantic operations defined around for this class.
* the same `Completion`. | ||
*/ | ||
def getCompletions(using Context): List[Completion] = { | ||
nameToDenots.toList.groupBy(_._1.toTermName.show).map { (name, namedDenots) => |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we avoided the groupBy
here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think so if we want to keep the current semantics although I'm still wondering of this is what we want for the future #11313 but changing this is not an intension of this PR
@liufengyun any more remarks or are we ready to merge this? |
I'll have another look soon. |
* taking care of filtering out not matching completions | ||
* and enabling easy resolving of conflicts between different denotations with the same name | ||
*/ | ||
case class CompletionScope private(nameToDenots: Map[Name, List[SingleDenotation]] = Map.empty) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The documentation does not say whether TermName and TypeName are merged or not.
.groupBy(_._1) | ||
.collect { | ||
case (name, (_, denots) :: Nil) => name -> denots | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems to me the code can be simplified with a fold
or while
-loop. The code above creates many intermediate objects and goes through the collection several times.
// Merge two scopes using mappings from `that` instead of from `this` in case of name clashes | ||
def mergeShadowedBy(that: CompletionScope) = CompletionScope(this.nameToDenots ++ that.nameToDenots) | ||
|
||
// Merge two scopes but discard mappings for names that appear in both scopes |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
// Merge two scopes but discard mappings for names that appear in both scopes | |
/** Merge two scopes but discard mappings for names that appear in both scopes */ |
Will/should a TermName a
considered the same as a TypeName a
?
*/ | ||
case class CompletionScope private(nameToDenots: Map[Name, List[SingleDenotation]] = Map.empty) { | ||
|
||
// Merge two scopes using mappings from `that` instead of from `this` in case of name clashes |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
// Merge two scopes using mappings from `that` instead of from `this` in case of name clashes | |
/** Merge two scopes using mappings from `that` instead of from `this` in case of name clashes */ |
Will/should a TermName a
considered the same as a TypeName a
?
.filter((name, denot) => include(denot.symbol, name)) | ||
.toList | ||
.groupBy(_._1) | ||
.map((name, namedDenots) => name.stripModuleClassSuffix -> namedDenots.map(_._2)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you please explain a little bit about namedDenots.map(_._2)
? The code might be simpler with a simple loop or fold
on namedDenotations
.
val imported = grouped.map { (owner, contexts) => | ||
contexts.collect { case context if context.isImportContext => | ||
importedCompletions(using context) | ||
}.foldLeft(CompletionScope.empty)(_.mergeDiscardingAmbiguities(_)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I still feel uneasy with the code inside scopeCompletions
. There are too many chaining of high-order functions. A recursive method or while-loop will be much better in readability and performance.
Could you try to rewrite the code in a plain style to encode the algorithm here? For reference, in the compiler there are many places we use recursion and while-loops to deal with the contex:
* No implicit search is tried for implicits following the receiver or for parameters of the def (D, E). | ||
*/ | ||
def tryApplyingExtensionMethod(methodRef: TermRef, receiver: Tree)(using Context): Option[Tree] = | ||
// Drop all parameters sections of an extension method following the receiver; the return type after truncation is not important |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
// Drop all parameters sections of an extension method following the receiver; the return type after truncation is not important | |
// Drop all parameters sections of an extension method following the receiver | |
// The return type after truncation is not important |
case TypeApply(fun, args) => TypeApply(replaceCallee(fun, replacement), args) | ||
case _: Ident => replacement | ||
|
||
val truncatedSym = methodRef.symbol.asTerm.copy(owner = defn.RootPackage, name = Names.termName(""), info = truncateExtension(methodRef.info)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
val truncatedSym = methodRef.symbol.asTerm.copy(owner = defn.RootPackage, name = Names.termName(""), info = truncateExtension(methodRef.info)) | |
val truncatedSym = methodRef.symbol.asTerm.copy(info = truncateExtension(methodRef.info)) |
It seems the name and owner don't matter here.
|
||
def isApplicableExtensionMethod(methodRef: TermRef, receiverType: Type)(using Context): Boolean = | ||
methodRef.symbol.is(ExtensionMethod) && !receiverType.isBottomType && | ||
tryApplyingExtensionMethod(methodRef, Typed(EmptyTree, TypeTree(receiverType))).nonEmpty |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's better to avoid EmptyTree
inside expressions:
tryApplyingExtensionMethod(methodRef, Typed(EmptyTree, TypeTree(receiverType))).nonEmpty | |
tryApplyingExtensionMethod(methodRef, nullLiteral.asInstance(receiverType))).nonEmpty |
@liufengyun it took me quite some time but I think I managed to significantly simplify the code in many places while also handling some additional corner cases. |
@prolativ Thanks a lot for taking the effort to improve the PR. I'll review and get it merged soon. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM, well done 🎉
|
||
/** Completions introduced by imports directly in this context. | ||
* Completions from outer contexts are not included. | ||
*/ | ||
private def importedCompletions(using Context): CompletionScope = { | ||
private def importedCompletions(using Context): Map[Name, Seq[SingleDenotation]] = { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe add a type alias type CompletionMap = Map[Name, Seq[SingleDenotation]]
*/ | ||
private case class ScopedDenotations( | ||
denots: Seq[SingleDenotation], | ||
scope: Scope, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For this field, maybe use Context
-- usually we use context to represent context information.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The point is that multiple contexts can share the same scope e.g. when you do multiple imports one after another
import Foo.{xxx => zzz}
import Bar.{yyy => zzz}
and scope seems to be the only part of a context that seems to matter here for the distinction
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was not clear in my previous message. I meant to use ctx
here and use ctx.scope
in the comparison.
val typesFirst = denots.sortWith((d1, d2) => d1.isType && !d2.isType) | ||
val desc = description(typesFirst) | ||
Completion(name.show, desc, typesFirst.map(_.symbol)) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Indentation is incorrect hre for the block.
This is needed after #10940 got merged.
One disputable aspect might be whether an extension method should be suggested in code completion if it requires a given which is not available in scope.
Before the syntax change only trailing using clauses were allowed in extensions, e.g.
and the current behaviour was that for
123.x
123.xxx
would be suggested even if there's no givenFoo
.On the one hand not suggesting
xxx
would de-pollute the namespace and cause less confusion if there are multiple extension methods available which are applicable to anything that implements a type class, e.g.On the other hand it is still possible to call such methods if you provide the implicit explicitly.
So it seems to keep the current behaviour (to suggest
xxx
instead of hide it) as it is consistent with how code completion currently works with normal methods and methods introduced by implicit classes.With leading using clauses, e.g.
the situation is slightly different. If a given instance of
Foo
is not in scope it cannot be passed to the method explicitly when one calls it like123.xxx
so the argument against not suggestingxxx
doesn't apply anymore.What's more the type of the receiver parameter might sometimes depend on the leading implicits.
So it makes sense not to suggest
xxx
when there's no givenFoo
. This also seems to be how completion works with implicit classes.In case of import suggestions this currently seems to work analogically to code completions so I decided to keep this analogy (which also allowed some code reuse) even though sometimes the suggested import might not be enough to make an extension method work