Skip to content

Redesign ExprChain around a head, segments and a terminal call - #3385

Open
nojaf wants to merge 5 commits into
fsprojects:mainfrom
nojaf:fix-3364
Open

Redesign ExprChain around a head, segments and a terminal call#3385
nojaf wants to merge 5 commits into
fsprojects:mainfrom
nojaf:fix-3364

Conversation

@nojaf

@nojaf nojaf commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

The old model held a chain as a flat ChainLink list with an isLastLink flag deciding whether a space could precede a call's opening paren. Position and permission to add a space are only incidentally the same thing, and they came apart inside a dot-lambda body: with space_before_uppercase_invocation enabled, _.Substring(0, 16).ToLower() gained a space and stopped compiling, because the printer had no way to know it was inside a _. lambda (#3364).

A chain is now a head, a list of ChainSegment, and a ChainTerminal. Each segment carries its own dot, since a dot always belongs with what follows it, which makes two adjacent dots unrepresentable. Only the terminal negotiates a space. That is a grammar constraint rather than a style choice: a space mid-chain reparses a.Foo (x).Bar() as a.Foo ((x).Bar()). ChainTerminal.NoSpaceAllowed therefore makes 'no space is permitted here' a property of the node, instead of something the printer has to rediscover from context.

Nodes that were chains in all but name are absorbed, leaving Expr.Chain as the single representation of a dotted expression:

  • ExprDotLambda
  • ExprAppLongIdentAndSingleParenArg
  • ExprDotIndexedGet
  • AppWithLambda with no prefix arguments

ExprNestedIndexWithoutDot is removed as dead; nothing ever constructed it. This breaks the public Oak API: a dotted long ident such as a.b.c now yields Expr.Chain rather than Expr.OptVar in expression position.

Layout changes follow from the model. A chain whose only call is its last step keeps the receiver, navigation and method name together and breaks the call's arguments, exactly as a call with no receiver would. Two or more calls, or a call followed by further navigation, use the leading-dot pipeline. A chain of pure property access fills lines greedily rather than fanning out one member per line. docs/docs/contributors/Chains.md states the rules in full; they are intended for the F# style guide and live under Contributors until they are adopted there.

Fixes a second source of invalid F#, unrelated to spacing. A conditional directive attached to an intermediate call's argument pushed the opening paren onto its own line, which reparses the chain (FCS error 597). The paren now stays welded to the member name and the break lands after it.

Branches in the chain transformer that are unreachable by construction now raise the new InvariantViolationException rather than silently returning an empty list. It derives from FormatException, which matters because the CLI selects its message by exception type and a bare failwith fell through to an empty message at normal verbosity. Each carries the source range of the construct involved and points at fantomas-tools.

Adds a Coverage pipeline (dotnet fsi build.fsx -- -p Coverage) built on AltCover and ReportGenerator. It is how the unreachable branches were identified, and how two branches that looked dead were shown to be reachable but untested, which would have turned valid F# into a crash.

The old model held a chain as a flat `ChainLink list` with an `isLastLink`
flag deciding whether a space could precede a call's opening paren.
Position and permission to add a space are only incidentally the same
thing, and they came apart inside a dot-lambda body: with
`space_before_uppercase_invocation` enabled, `_.Substring(0, 16).ToLower()`
gained a space and stopped compiling, because the printer had no way to
know it was inside a `_.` lambda (fsprojects#3364).

A chain is now a head, a list of `ChainSegment`, and a `ChainTerminal`.
Each segment carries its own dot, since a dot always belongs with what
follows it, which makes two adjacent dots unrepresentable. Only the
terminal negotiates a space. That is a grammar constraint rather than a
style choice: a space mid-chain reparses `a.Foo (x).Bar()` as
`a.Foo ((x).Bar())`. `ChainTerminal.NoSpaceAllowed` therefore makes 'no
space is permitted here' a property of the node, instead of something the
printer has to rediscover from context.

Nodes that were chains in all but name are absorbed, leaving `Expr.Chain`
as the single representation of a dotted expression:

  - ExprDotLambda
  - ExprAppLongIdentAndSingleParenArg
  - ExprDotIndexedGet
  - AppWithLambda with no prefix arguments

ExprNestedIndexWithoutDot is removed as dead; nothing ever constructed it.
This breaks the public Oak API: a dotted long ident such as `a.b.c` now
yields `Expr.Chain` rather than `Expr.OptVar` in expression position.

Layout changes follow from the model. A chain whose only call is its last
step keeps the receiver, navigation and method name together and breaks
the call's arguments, exactly as a call with no receiver would. Two or
more calls, or a call followed by further navigation, use the leading-dot
pipeline. A chain of pure property access fills lines greedily rather than
fanning out one member per line. docs/docs/contributors/Chains.md states
the rules in full; they are intended for the F# style guide and live under
Contributors until they are adopted there.

Fixes a second source of invalid F#, unrelated to spacing. A conditional
directive attached to an intermediate call's argument pushed the opening
paren onto its own line, which reparses the chain (FCS error 597). The
paren now stays welded to the member name and the break lands after it.

Branches in the chain transformer that are unreachable by construction now
raise the new InvariantViolationException rather than silently returning
an empty list. It derives from FormatException, which matters because the
CLI selects its message by exception type and a bare failwith fell through
to an empty message at normal verbosity. Each carries the source range of
the construct involved and points at fantomas-tools.

Adds a Coverage pipeline (dotnet fsi build.fsx -- -p Coverage) built on
AltCover and ReportGenerator. It is how the unreachable branches were
identified, and how two branches that looked dead were shown to be
reachable but untested, which would have turned valid F# into a crash.

global.json rolls forward to the latest SDK feature band so the repo
builds with only 10.0.301 installed.
Comment thread src/Fantomas.Core/CodePrinter.fs Fixed
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.

2 participants