Skip to content

Code actions to add and remove anonymous functions#4789

Merged
lpil merged 7 commits into
gleam-lang:mainfrom
treuherz:anon-fn-code-actions
Mar 31, 2026
Merged

Code actions to add and remove anonymous functions#4789
lpil merged 7 commits into
gleam-lang:mainfrom
treuherz:anon-fn-code-actions

Conversation

@treuherz

Copy link
Copy Markdown
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

Comment thread compiler-core/src/language_server/code_action.rs Outdated

@lpil lpil left a comment

Copy link
Copy Markdown
Member

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
Copy Markdown
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
Copy Markdown
Member

Choose a reason for hiding this comment

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

Document this please

Comment thread compiler-core/src/language_server/code_action.rs Outdated
}
}

impl<'ast> ast::visit::Visit<'ast> for WrapInAnonymousFunction<'ast> {

Copy link
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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!

Comment thread compiler-core/src/language_server/code_action.rs

if call_arguments != expected_arguments {
return;
}

Copy link
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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 🙏

Comment thread language-server/src/tests/action.rs
@lpil
lpil marked this pull request as draft July 17, 2025 12:25
@treuherz

Copy link
Copy Markdown
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

llakala commented Jul 18, 2025

Copy link
Copy Markdown

Probably also want a changelog entry, right?

Thanks for doing this!

@treuherz

Copy link
Copy Markdown
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

lpil commented Jul 21, 2025

Copy link
Copy Markdown
Member

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
Copy Markdown
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

@lpil lpil left a comment

Copy link
Copy Markdown
Member

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!

Comment thread compiler-core/src/parse/extra.rs Outdated
best = self.comments.get(index).copied();
search_list = search_list.get(0..index).unwrap_or(&[]);
}
best

Copy link
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this snapshot unused now?

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

Copy link
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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

lpil commented Aug 24, 2025

Copy link
Copy Markdown
Member

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
@treuherz
treuherz force-pushed the anon-fn-code-actions branch from 203a523 to 58626bf Compare September 9, 2025 13:42
@treuherz

treuherz commented Sep 9, 2025

Copy link
Copy Markdown
Contributor Author

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 lpil left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thank you! I've left a bunch of notes inline.

Sorry for the delay, my inbox has got out of control!

Comment thread compiler-core/src/ast/untyped.rs Outdated
pub enum FunctionLiteralKind {
Capture { hole: SrcSpan },
Anonymous { head: SrcSpan },
Anonymous { head: SrcSpan, body: SrcSpan },

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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) }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this snapshot unused now?


----- AFTER ACTION
pub fn main() {
1 |> fn(int) { wibble(2, _)(int) } |> wobble

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
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"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's not offer the code action in this situation, as the code produces is undesirable

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Excluded along with other captures.

Comment thread compiler-core/src/parse/extra.rs Outdated
best = self.comments.get(index);
search_list = search_list.get(0..index).unwrap_or(&[]);
}
best.copied()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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?

Comment thread compiler-core/src/ast/typed.rs Outdated
type_: Arc<Type>,
fun: Box<Self>,
arguments: Vec<CallArg<Self>>,
arguments_start: Option<u32>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why is this optional?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Position information comes from parsing, this data structure comes from analysis, which comes after parsing, so it must always be present.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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>?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Sounds good! Be sure to include detailed documentation for this field, as it's somewhat unclear.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

#[test]
fn wrap_capturing_pipeline_step_in_anonymous_function() {
assert_code_action!(
WRAP_IN_ANONYMOUS_FUNCTION,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

In this case the code action text should be "Expand function capture"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'm going to exclude this case, the existing "Expand function capture" refactor has it covered.

@lpil
lpil marked this pull request as draft December 4, 2025 11:24
@treuherz
treuherz force-pushed the anon-fn-code-actions branch 3 times, most recently from 6a3d96c to 9ff8e58 Compare December 20, 2025 18:46
@treuherz
treuherz marked this pull request as ready for review December 20, 2025 18:55
@giacomocavalieri

Copy link
Copy Markdown
Member

Hello! This has some conflicts with the main branch, would you mind rebasing if you think this is ready for review?

@lpil
lpil marked this pull request as draft January 19, 2026 11:22
@treuherz
treuherz force-pushed the anon-fn-code-actions branch 2 times, most recently from 6638cd3 to 7d8eb3f Compare February 15, 2026 15:37
@treuherz
treuherz marked this pull request as ready for review February 15, 2026 15:42
@treuherz

Copy link
Copy Markdown
Contributor Author

Rebased on main

Comment thread compiler-core/src/ast/typed.rs Outdated
type_: Arc<Type>,
fun: Box<Self>,
arguments: Vec<CallArg<Self>>,
arguments_start: Option<u32>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Sounds good! Be sure to include detailed documentation for this field, as it's somewhat unclear.

Comment thread compiler-core/src/parse/extra.rs Outdated
})
.ok()
.and_then(|index| self.comments.get(index).copied())
// Helper function to find a comment that is between the given start

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@treuherz
treuherz force-pushed the anon-fn-code-actions branch 2 times, most recently from 57697f5 to aab9d60 Compare March 7, 2026 23:02
@treuherz

treuherz commented Mar 7, 2026

Copy link
Copy Markdown
Contributor Author

Hope the comments are sufficiently addressed. I've tried to sane-ify the commits again too

@lpil lpil left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Lovely!!! A couple small notes inline. I'll rebase and fix them rather than blocking you for longer, thank you!

Comment thread compiler-core/src/parse/extra.rs Outdated
Comment thread compiler-core/src/ast/typed.rs Outdated
@lpil
lpil force-pushed the anon-fn-code-actions branch from aab9d60 to 52f5392 Compare March 31, 2026 14:33

@lpil lpil left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thank you!!!!!

@lpil
lpil merged commit f862e75 into gleam-lang:main Mar 31, 2026
11 of 12 checks passed
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

4 participants