Code actions to add and remove anonymous functions#4789
Conversation
lpil
left a comment
There was a problem hiding this comment.
Thank you! Looking really good! I've left a bunch of comments inline, let me know if anything is unclear.
Please un-draft the PR and tag me when you are ready for a review. Thank you
| variables | ||
| } | ||
|
|
||
| fn from_expr(expr: &TypedExpr) -> Self { |
There was a problem hiding this comment.
No abbreviations please 🙏 expr -> expression
It's not very clear to me from the name what this does. A doc comment would help a lot, and maybe a more descriptive name
| } | ||
| } | ||
|
|
||
| struct FunctionToWrap { |
| } | ||
| } | ||
|
|
||
| impl<'ast> ast::visit::Visit<'ast> for WrapInAnonymousFunction<'ast> { |
There was a problem hiding this comment.
I think this is computing the code action for all non-nested function references that are assigned to variables or are passed as arguments, but we want only the ones that are currently within the range specified by the code action parameters.
I think it would ideally be any non-called function rather than specifically these two positions in the AST, and think the non-nested restriction should be removed.
There was a problem hiding this comment.
the range check makes sense, I'd missed it before bc I was looking at RemoveUnusedImports which obviously doesn't need it 🤦
Lots of the other actions store their TextEdits in the struct but only need it there for the range check, which can be done with just a LineNumbers. Storing LineNumbers seems more natural to me but I'm splitting hairs and will defer to consistency if you like
There was a problem hiding this comment.
The non-nested restriction was inadvertent, if I'm reading it right I need to call back into the visitor after I'm done?
There was a problem hiding this comment.
Using line numbers seems good to me!
|
|
||
| if call_arguments != expected_arguments { | ||
| return; | ||
| } |
There was a problem hiding this comment.
Document the logic in this section please
| } | ||
| } | ||
|
|
||
| // match fn bodies with only a single function call |
There was a problem hiding this comment.
Capital letters at the start of comments please, and explain why something is done rather than what is done. Thank you
| const GENERATE_VARIANT: &str = "Generate variant"; | ||
| const REMOVE_BLOCK: &str = "Remove block"; | ||
| const WRAP_IN_ANONYMOUS_FUNCTION: &str = "Wrap in anonymous function"; | ||
| const UNWRAP_ANONYMOUS_FUNCTION: &str = "Unwrap anonymous function"; |
There was a problem hiding this comment.
To me this sounds like the anonymous function remains but its wrapper will be removed. "Remove anonymous function wrapper" perhaps?
| "import gleam/list | ||
|
|
||
| pub fn main() { | ||
| list.map([1, 2, 3], op) |
There was a problem hiding this comment.
2 space indentation for Gleam please 🙏
|
Ok, there was more to do here than I thought but the code is better than before 🎉 I've copped out and not offered the action on functions with comments in. I think it's doable, but found an issue I wanted some advice on while I was on my way there. The |
|
Probably also want a changelog entry, right? Thanks for doing this! |
|
Ok, I've fixed |
|
Comments shouldn't change the behaviour of the language server, so we would want to provide code actions regardless of whether or not there are comments. Having imperfect formatting afterwards when there's comments doesn't sound too bad, the programmer can run the formatter. Do you have some examples of what the output looks like? I've less context than you, but it seems like comments inside the body of the function shouldn't be much of an issue as they can be left as-is: fn(x, y) {
// comment
thingy(x, y)
} // comment
thingyFor comments inside the parameters I think we can discard them happily. fn(
// Hello
x,
y,
) {
thingy(x, y)
} thingy |
|
It's not really anything that the autoformatter wouldn't tidy up. There are some snapshots in this commit, the worst it gets is probably things like this one where the comment has moved lines but that's not too big a deal. If that's acceptable (or close enough to work on) I'll include it in this PR |
| best = self.comments.get(index).copied(); | ||
| search_list = search_list.get(0..index).unwrap_or(&[]); | ||
| } | ||
| best |
There was a problem hiding this comment.
Why are we now traversing the comments and copying their locations multiple times?
There was a problem hiding this comment.
I've moved the copy out of the loop but I think the multiple-traversal is necessary to ensure we return the first comment in the range rather than just a comment in the range. I added some failing tests for the previous implementation in this commit, I can comment this new one more to explain the method properly
| /// Helper struct, a target for [WrapInAnonymousFunction]. | ||
| struct FunctionToWrap { | ||
| location: SrcSpan, | ||
| arguments: Vec<Arc<Type>>, |
There was a problem hiding this comment.
This could be borrowed I think
| arguments: Vec<Arc<Type>>, | |
| arguments: &'a [Arc<Type>], |
There was a problem hiding this comment.
I can't figure out how to get that past borrowck 😕. The arguments are pulled from a field of an Arc<Type> here. If we want to avoid cloning the argument list then I could clone the whole Arc<Fn> for the function instead and pull the arguments out later
| --- | ||
| ----- BEFORE ACTION | ||
| pub fn main() { | ||
| let f = fn(in) { ception(in) } |
There was a problem hiding this comment.
I don't think we should be offering to wrap an anonymous function in another anonymous function
| --- | ||
| ----- BEFORE ACTION | ||
| pub fn main() { | ||
| 1 |> wibble |> wobble |
There was a problem hiding this comment.
Could we have a test for wibble taking 2 arguments please
|
|
||
| // We need to delete the anonymous function's head but preserve | ||
| // comments between it and the inner function call. | ||
| edits.delete(self.span_until_comment(SrcSpan { |
There was a problem hiding this comment.
Rather than seek through the comments multiple times I think we could use the location stored in the FunctionLiteralKind::Anonymous variant.
There was a problem hiding this comment.
I think by pulling more data through from the parser we can eliminate both comment-checks, but I'm not sure we can currently eliminate either.
Here's what I think we get from the parser currently:
fn(arg_1: T1, arg_2: T2) -> T3 { [comments] inner_function(arg_1, arg_2) [comments] }
└─────────────head───────────┘ └────inner───┘
└─────────────────────────────────────────outer──────────────────────────────────────┘
So means if I pull the opening brace through the parser I can eliminate this first comment-seek, and if I pull the span of the inner argument-list through I can eliminate the second. Without either I think we risk mangling things if the input isn't well-formatted (e.g. too much space before the opening brace)
There was a problem hiding this comment.
I've pulled more locations through the parser, and have followed this approach so we now don't care about comments. I've left the bug-fix in though!
|
|
||
| // Now we need to delete the inner function call's arguments, | ||
| // preserving comments before the outer function tail. | ||
| edits.delete(self.span_until_comment(SrcSpan { |
There was a problem hiding this comment.
In the TypedExpr::Call variant we could store the byte index of the ( to make it easier to get that position.
|
Do remember to undraft this PR and tag me when you're ready for a review! |
a0c331f to
203a523
Compare
203a523 to
58626bf
Compare
|
Sorry for the delay, I wanted some advice on which route to take to fix the problems, and then life got in the way. I think the new approach is better, and I've rebased on top of main. Ready for review @lpil |
lpil
left a comment
There was a problem hiding this comment.
Thank you! I've left a bunch of notes inline.
Sorry for the delay, my inbox has got out of control!
| pub enum FunctionLiteralKind { | ||
| Capture { hole: SrcSpan }, | ||
| Anonymous { head: SrcSpan }, | ||
| Anonymous { head: SrcSpan, body: SrcSpan }, |
There was a problem hiding this comment.
Isn't the start of body always the end of head?
This data also is duplicating the data of the containing function data structure I think. It would be fab to not have that duplication there
There was a problem hiding this comment.
Using body.start skips past the opening brace and any spaces between it and the first statement of the body. I can do that with next_nonwhitespace instead.
| --- | ||
| ----- BEFORE ACTION | ||
| pub fn main() { | ||
| let f = fn(in) { ception(in) } |
|
|
||
| ----- AFTER ACTION | ||
| pub fn main() { | ||
| 1 |> fn(int) { wibble(2, _)(int) } |> wobble |
There was a problem hiding this comment.
| 1 |> fn(int) { wibble(2, _)(int) } |> wobble | |
| 1 |> fn(int) { wibble(2, int) } |> wobble |
I think in this case the code action text should be "Expand function capture"
There was a problem hiding this comment.
I'm going to exclude this case, the existing "Expand function capture" refactor has it covered.
|
|
||
| ----- AFTER ACTION | ||
| pub fn main() { | ||
| 1 |> fn(int, int_2) { wibble(int, int_2) }(2) |> wobble |
There was a problem hiding this comment.
Let's not offer the code action in this situation, as the code produces is undesirable
There was a problem hiding this comment.
Excluded along with other captures.
| best = self.comments.get(index); | ||
| search_list = search_list.get(0..index).unwrap_or(&[]); | ||
| } | ||
| best.copied() |
There was a problem hiding this comment.
Why has this changed? It is much harder to understand now, and it seems to do a lot more work than the binary search of before.
There was a problem hiding this comment.
A simple binary search doesn't guarantee it finds the first match only a match. Without this change the tests I added below don't all pass correctly.
There was a problem hiding this comment.
Wouldn't it be more efficient to bisection search and then seek backwards? It would be a lot easier to understand, I'm finding this code very hard to read.
There was a problem hiding this comment.
I don't know if that would always be more efficient, without foreknowledge of how long big a start/end span is being given. I've tried to make the function a bit clearer, wdyt?
| type_: Arc<Type>, | ||
| fun: Box<Self>, | ||
| arguments: Vec<CallArg<Self>>, | ||
| arguments_start: Option<u32>, |
There was a problem hiding this comment.
There are a bunch of places in inference code where this information isn't readily available. I'm not wild about what I've ended up with but not sure how else to go about this
There was a problem hiding this comment.
Position information comes from parsing, this data structure comes from analysis, which comes after parsing, so it must always be present.
There was a problem hiding this comment.
The problem is cases like infer_insert_pipe or infer_apply_to_call_pipe where I don't think value is clear or well-defined. What if it was instead something like argument_parentheses: Option<SrcSpan>?
There was a problem hiding this comment.
Sounds good! Be sure to include detailed documentation for this field, as it's somewhat unclear.
| #[test] | ||
| fn wrap_capturing_pipeline_step_in_anonymous_function() { | ||
| assert_code_action!( | ||
| WRAP_IN_ANONYMOUS_FUNCTION, |
There was a problem hiding this comment.
In this case the code action text should be "Expand function capture"
There was a problem hiding this comment.
I'm going to exclude this case, the existing "Expand function capture" refactor has it covered.
6a3d96c to
9ff8e58
Compare
|
Hello! This has some conflicts with the main branch, would you mind rebasing if you think this is ready for review? |
6638cd3 to
7d8eb3f
Compare
|
Rebased on main |
| type_: Arc<Type>, | ||
| fun: Box<Self>, | ||
| arguments: Vec<CallArg<Self>>, | ||
| arguments_start: Option<u32>, |
There was a problem hiding this comment.
Sounds good! Be sure to include detailed documentation for this field, as it's somewhat unclear.
| }) | ||
| .ok() | ||
| .and_then(|index| self.comments.get(index).copied()) | ||
| // Helper function to find a comment that is between the given start |
There was a problem hiding this comment.
I think this could be written more clearly and more efficiently something like this:
self.comments
.partition_point(|comment| comment.start >= start)
.ok()
.and_then(|index| self.comments.get(index).copied())
.filter(|comment| comment.start < end)There was a problem hiding this comment.
partition_point returns a usize, and doesn't define its return value if no elements match the predicate i.e. if no comment starts within the given span. It also doesn't account for the case where a comment ends in the span, which the existing code.
I'm really sorry this function has been such a source of difficulty. To be clear, in the current version of this PR, it isn't even used in the new code action. I've just kept the change here for the bugfix.
I'm going to push a version that takes the pre-existing implementation and then seeks backwards from that point, as you suggested before. I think the worst-case scenario could be worse but it's probably negligible, and you're right that it's much clearer.
57697f5 to
aab9d60
Compare
|
Hope the comments are sufficiently addressed. I've tried to sane-ify the commits again too |
lpil
left a comment
There was a problem hiding this comment.
Lovely!!! A couple small notes inline. I'll rebase and fix them rather than blocking you for longer, thank you!
previously this could have returned any comment in the span rather than guaranteeing the first
aab9d60 to
52f5392
Compare
I've had a go at implementing new code actions discussed in #4614, one to wrap a reference to a function in an anonymous function, and one to unwrap a trivial anonymous function into a bare reference.
These seemed pretty straightforward to implement so I'm a bit concerned I've missed a whole slew of edge cases. Any pointers as to more test cases I should add would be great
I know the issue was inconclusive about the names. These ones seemed straightforward but bikeshedding is welcome
Closes #4614