Skip to content

Keep the cursor on control-expression keywords - #3506

Merged
nojaf merged 3 commits into
fsprojects:mainfrom
mmabdpr:fix-3387
Oct 1, 2026
Merged

nojaf merged 3 commits into
fsprojects:mainfrom
mmabdpr:fix-3387

Conversation

@mmabdpr

@mmabdpr mmabdpr commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

Fixes #3387

What changed

  • Record cursor positions when printing if, then, elif, else if, match, match!, and with.
  • Track else and if separately in ElseIfNode so extra spaces or line breaks in the source don't shift the cursor within either keyword.
  • Formatting output, comment placement, and cursor fallback in whitespace remain unchanged.

Why

As noted in #3387:

  1. genControlExpressionStartCore emitted keyword text directly without calling recordCursorNode.
  2. Trivia.insertCursor only attached cursors to SingleTextNode, skipping the separate else and if nodes in ElseIfNode.

To fix this:

  • Threaded recordCursorNode through genStart and genEnd in genControlExpressionStartCore.
  • Replaced the anonymous object expressions in ElseIfNode with ElseIfKeywordNode and allowed insertCursor to attach cursors to them directly.

Design notes

  • else and if tracked separately: Anchoring each token independently avoids cursor drift when else and if have extra spaces or sit on separate lines in the source.
  • Direct type match vs. marker interface: I considered adding a CursorAnchor interface, but matched SingleTextNode and ElseIfKeywordNode directly in Trivia.fs to 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.fs covering short and multiline conditionals, elif, match, match!, comments around keywords, and else if with extra spacing or newlines. All 19 cursor tests pass.

mmabdpr and others added 3 commits September 30, 2026 18:08
- 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 nojaf left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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:

  • ElseIfNode is gone. It used object expressions to reroute the trivia between else and if while it was being attached. IfKeywordNode.ElseIf now holds the two keywords as plain SingleTextNodes, and CodePrinter decides where the trivia ends up. The cursor then works on both keywords without any special node type. This makes ElseIfKeywordNode unnecessary.
  • genControlExpressionStart no longer takes a Choice. match passes its keyword directly, and a new genIfStart handles if, elif and else if in one place.
  • New genSingleTextNodeText helper. 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 SingleTextNode stores a cursor now. It was the only node that ever held one, so the cursor members moved off Node.
  • Tests rewritten in the existing style. I replaced the cursor tests with one keyword and one position per test, following the formatWithCursor |> assertCursor pattern already in CursorTests.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!

@nojaf
nojaf merged commit 578727b into fsprojects:main Oct 1, 2026
6 checks passed
This was referenced Oct 1, 2026
This was referenced Oct 6, 2026
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.

Cursor is lost when placed on if, then or else if keywords

2 participants