Skip to content

Conversation

treuherz
Copy link
Contributor

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

Copy link
Member

@lpil lpil left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Document this please

}
}

impl<'ast> ast::visit::Visit<'ast> for WrapInAnonymousFunction<'ast> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The non-nested restriction was inadvertent, if I'm reading it right I need to call back into the visitor after I'm done?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using line numbers seems good to me!


if call_arguments != expected_arguments {
return;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Document the logic in this section please

}
}

// match fn bodies with only a single function call
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 space indentation for Gleam please 🙏

",
find_position_of("fn(int)").to_selection()
);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add more tests to cover all the possible scenarios. We'll want to cover:

  • Labelled arguments
  • Pipes
  • Qualified functions from other modules
  • Records
  • Comments

And anything else you can think of!

@lpil lpil marked this pull request as draft July 17, 2025 12:25
@treuherz
Copy link
Contributor Author

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 first_comment_between helper has a binary search which will return any comment in a given line-span, not necessarily the first. We could guarantee it finds the first by recursing back into the list with smaller boundaries, but I haven't written recursive algorithms in Rust before and I don't know how to tell if I'm going to blow up the stack.

@treuherz treuherz marked this pull request as ready for review July 18, 2025 09:44
@llakala
Copy link

llakala commented Jul 18, 2025

Probably also want a changelog entry, right?

Thanks for doing this!

@treuherz
Copy link
Contributor Author

Ok, I've fixed first_comment_between without recursion but actually unwrapping functions with comments is a bit of a minefield because unless I re-indent them afterwards it looks rubbish. How would you feel about keeping this as-is (i.e. not offering the action on functions with comments) and improving that in a follow-up?

@treuherz treuherz requested a review from lpil July 19, 2025 12:05
@lpil
Copy link
Member

lpil commented Jul 21, 2025

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
  thingy

For comments inside the parameters I think we can discard them happily.

fn(
  // Hello
  x,
  y,
) {
  thingy(x, y)
}
  thingy

@treuherz
Copy link
Contributor Author

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

Copy link
Member

@lpil lpil left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome, really nice work!! I've left a few small notes inline.

When you are ready for a review please un-draft this PR and tag me for a review. Thank you!

best = self.comments.get(index).copied();
search_list = search_list.get(0..index).unwrap_or(&[]);
}
best
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we now traversing the comments and copying their locations multiple times?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be borrowed I think

Suggested change
arguments: Vec<Arc<Type>>,
arguments: &'a [Arc<Type>],

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should be offering to wrap an anonymous function in another anonymous function

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

---
----- BEFORE ACTION
pub fn main() {
1 |> wibble |> wobble
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we have a test for wibble taking 2 arguments please

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added


// 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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than seek through the comments multiple times I think we could use the location stored in the FunctionLiteralKind::Anonymous variant.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the TypedExpr::Call variant we could store the byte index of the ( to make it easier to get that position.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see previous

@lpil lpil marked this pull request as draft August 7, 2025 12:18
@lpil
Copy link
Member

lpil commented Aug 24, 2025

Do remember to undraft this PR and tag me when you're ready for a review!

@treuherz treuherz force-pushed the anon-fn-code-actions branch from a0c331f to 203a523 Compare September 9, 2025 13:40
@treuherz treuherz marked this pull request as ready for review September 9, 2025 13:41
This increases the blast radius of the change a bit, but means we
don't need to worry about comments any more.
@treuherz treuherz force-pushed the anon-fn-code-actions branch from 203a523 to 58626bf Compare September 9, 2025 13:42
@treuherz
Copy link
Contributor Author

treuherz commented Sep 9, 2025

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Code action to anonymize/deanonymize a function
3 participants