Repository navigation
Keep the cursor on control-expression keywords - #3506
Merged
Merged
Conversation
- Record cursor positions when control-expression keywords are printed; previously they were written as plain text without cursor tracking. - Attach cursors directly to the separate `else` and `if` nodes so they no longer lose their cursor during trivia handling. - Track `else` and `if` separately so extra spaces or line breaks in the source do not shift the cursor within either keyword. - Preserve existing comment routing and the approximate whitespace fallback. - Add regression tests for short and multiline conditionals, `elif`, `match` and `match!`, including comments and differently spaced `else if` pairs. Fixes fsprojects#3387
Record the cursor on `if`, `elif`, `then`, `match`, `match!` and `with` in genControlExpressionStartCore, and leave `else if` as it was. ElseIfKeywordNode and the change to Trivia.insertCursor are dropped. The `else if` case is better solved by giving ElseIfNode two plain SingleTextNode keywords and moving its trivia routing into CodePrinter, which is left for a separate change. The changelog entry now says `else if` is not covered yet. The cursor tests follow the existing pattern in CursorTests.fs again: one keyword and one position per test, without a custom helper or assertions on the formatted code. Directory.Build.rsp is removed.
ElseIfNode wrapped the `else` and `if` keywords in object expressions that rerouted their trivia while it was being attached, holding on to the condition only to push comments into it. Those keywords were not SingleTextNodes, so a cursor placed on them was lost. IfKeywordNode.ElseIf now holds the two keywords as SingleTextNodes and ElseIfNode is removed. CodePrinter decides where the trivia between them goes: after `else if`, or on its own line before the condition. Output is unchanged, and the cursor keeps its place on both keywords. genControlExpressionStart takes the pieces of its layout instead of a Choice between a keyword and an IfKeywordNode. `match` passes its keyword directly, and genIfStart handles `if`, `elif` and `else if` in one place. Code that writes a token's trivia apart from its text now uses genSingleTextNodeText, which also records the cursor. That fixes the same loss on the closing bracket of a cramped list or array. Only a SingleTextNode ever holds a cursor, so AddCursor and TryGetCursor move from the Node interface and NodeBase to SingleTextNode. The changelog's unreleased section becomes 8.0.6.
nojaf
approved these changes
Oct 1, 2026
nojaf
left a comment
Contributor
There was a problem hiding this comment.
Thanks a lot for picking this up, @mmabdpr! Your analysis was spot on: the cursor recording that genControlExpressionStartCore skipped is exactly what broke if, then and match, and that part of your fix is in the final version.
When I wrote #3387, I already had some refactoring in mind that the issue didn't spell out. I should have been clearer about that up front, and it's why I went further on top of your branch instead of asking for more changes:
ElseIfNodeis gone. It used object expressions to reroute the trivia betweenelseandifwhile it was being attached.IfKeywordNode.ElseIfnow holds the two keywords as plainSingleTextNodes, andCodePrinterdecides where the trivia ends up. The cursor then works on both keywords without any special node type. This makesElseIfKeywordNodeunnecessary.genControlExpressionStartno longer takes aChoice.matchpasses its keyword directly, and a newgenIfStarthandlesif,elifandelse ifin one place.- New
genSingleTextNodeTexthelper. Code that writes a token's trivia separately from its text uses this helper, which also records the cursor. It turned up one more spot with the same bug, the closing bracket of a cramped list, which is fixed too. - Only
SingleTextNodestores a cursor now. It was the only node that ever held one, so the cursor members moved offNode. - Tests rewritten in the existing style. I replaced the cursor tests with one keyword and one position per test, following the
formatWithCursor |> assertCursorpattern already inCursorTests.fs. Sorry for the churn there. The project prefers tests that look like their neighbours over a new helper, even a well-built one.
None of this takes away from your work: your PR is what got this moving and showed exactly where the problem was. Thanks again, and I hope to see more from you!
This was referenced Oct 1, 2026
This was referenced Oct 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #3387
What changed
if,then,elif,else if,match,match!, andwith.elseandifseparately inElseIfNodeso extra spaces or line breaks in the source don't shift the cursor within either keyword.Why
As noted in #3387:
genControlExpressionStartCoreemitted keyword text directly without callingrecordCursorNode.Trivia.insertCursoronly attached cursors toSingleTextNode, skipping the separateelseandifnodes inElseIfNode.To fix this:
recordCursorNodethroughgenStartandgenEndingenControlExpressionStartCore.ElseIfNodewithElseIfKeywordNodeand allowedinsertCursorto attach cursors to them directly.Design notes
elseandiftracked separately: Anchoring each token independently avoids cursor drift whenelseandifhave extra spaces or sit on separate lines in the source.CursorAnchorinterface, but matchedSingleTextNodeandElseIfKeywordNodedirectly inTrivia.fsto avoid adding a public abstraction for just two types. Happy to change this if you'd prefer an interface, or explore that in a follow-up PR.Testing
Added 14 tests in
CursorTests.fscovering short and multiline conditionals,elif,match,match!, comments around keywords, andelse ifwith extra spacing or newlines. All 19 cursor tests pass.