Skip to content

Commit 3478a9e

Browse files
authored
Merge pull request #3352 from nojaf/fix-3159
Fix soundness in `?` operator chains via Expr.DynamicChain node
2 parents ab9eb58 + 1ce170c commit 3478a9e

6 files changed

Lines changed: 115 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## [Unreleased]
4+
5+
### Fixed
6+
7+
- Chained `?` operator accesses (e.g. `x?a("")?b(t)`) no longer add a space before parenthesised arguments, which previously changed how the next `?member` was parsed. Detected during AST→Oak transformation and represented as a new `Expr.DynamicChain` node so the printer can keep the chain tight; lone `?` calls still respect `SpaceBefore(Upper|Lower)caseInvocation`. [#3159](https://github.com/fsprojects/fantomas/issues/3159)
8+
39
## [8.0.0-alpha-011] - 2026-04-15
410

511
### Fixed

src/Fantomas.Core.Tests/DynamicOperatorTests.fs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,27 @@ let doc = x?a("")?b(t)?b(t)
8888
|> should
8989
equal
9090
"""
91-
let doc = x?a ("")?b (t)?b (t)
91+
let doc = x?a("")?b(t)?b(t)
92+
"""
93+
94+
[<Test>]
95+
let ``no space before paren args in dynamic operator chain, 3159`` () =
96+
formatSourceString
97+
"""
98+
x?a("")?b(t)
99+
"""
100+
config
101+
|> prepend newline
102+
|> should
103+
equal
104+
"""
105+
x?a("")?b(t)
92106
"""
93107

94108
[<Test>]
95109
let ``case determination issue with ExprAppSingleParenArgNode uppercase with config lower, 3088`` () =
96-
// We want to disobey SpaceBefore(Upper|Lower)caseInvocation inside of the ? chain because mixing it up can generate invalid code like x?a("arg")?B ("barg")?c("carg")
97-
// The space config that is used (Upper or Lower) depends on the case of the dynamic object, here x
110+
// Space before paren args of a `?` result is never added, regardless of SpaceBefore(Upper|Lower)caseInvocation.
111+
// Adding a space changes the AST when followed by another `?`, e.g. `X?a ("arg")?B`. See #3159.
98112
formatSourceString
99113
"""
100114
let doc1 = x?a("arg")?B("barg")?c("carg")
@@ -108,7 +122,7 @@ let doc2 = X?a("arg")?B("barg")?c("carg")
108122
equal
109123
"""
110124
let doc1 = x?a("arg")?B("barg")?c("carg")
111-
let doc2 = X?a ("arg")?B ("barg")?c ("carg")
125+
let doc2 = X?a("arg")?B("barg")?c("carg")
112126
"""
113127

114128
[<Test>]

src/Fantomas.Core/ASTTransformer.fs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,41 @@ let (|UnitExpr|_|) e =
696696
| SynExpr.Const(constant = SynConst.Unit) -> ValueSome e.Range
697697
| _ -> ValueNone
698698

699+
/// Matches the argument of a dynamic-chain item: a paren expression (excluding lambdas)
700+
/// or a unit literal.
701+
[<return: Struct>]
702+
let (|DynamicChainArg|_|) (e: SynExpr) =
703+
match e with
704+
| SynExpr.Const(constant = SynConst.Unit) -> ValueSome e
705+
| SynExpr.Paren(expr = inner) ->
706+
match inner with
707+
| SynExpr.Lambda _
708+
| SynExpr.MatchLambda _ -> ValueNone
709+
| _ -> ValueSome e
710+
| _ -> ValueNone
711+
712+
/// Walks an expression outermost-to-innermost, prepending each `?member` (and any paren arg)
713+
/// onto the accumulator. Because we recurse from outer to inner, the resulting list is in
714+
/// source order without a reversal step.
715+
[<TailCall>]
716+
let rec visitDynamicChain acc e =
717+
match e with
718+
| SynExpr.App(_, false, SynExpr.Dynamic(funcExpr, _, memberExpr, _), (DynamicChainArg _ as parenArg), _) ->
719+
visitDynamicChain ((memberExpr, Some parenArg) :: acc) funcExpr
720+
| SynExpr.Dynamic(funcExpr, _, memberExpr, _) -> visitDynamicChain ((memberExpr, None) :: acc) funcExpr
721+
| _ -> e, acc
722+
723+
/// Detects two or more consecutive `?` operator accesses (e.g. `x?a("")?b(t)`),
724+
/// returning the leading expression and the ordered list of items.
725+
/// Each item is a member expression and an optional paren/unit argument.
726+
[<return: Struct>]
727+
let (|DynamicChain|_|) (e: SynExpr) =
728+
let leading, items = visitDynamicChain [] e
729+
730+
match items with
731+
| _ :: _ :: _ -> ValueSome(leading, items)
732+
| _ -> ValueNone
733+
699734
[<return: Struct>]
700735
let (|ParenExpr|_|) e =
701736
match e with
@@ -1254,6 +1289,23 @@ let mkExpr (creationAide: CreationAide) (e: SynExpr) : Expr =
12541289
|> Expr.BeginEnd
12551290
else
12561291
mkParenExpr creationAide lpr e rpr pr |> Expr.Paren
1292+
| DynamicChain(leading, items) ->
1293+
let chainItems =
1294+
items
1295+
|> List.map (fun (memberExpr, parenArg) ->
1296+
let memberExpr' = mkExpr creationAide memberExpr
1297+
1298+
let parenArg' = parenArg |> Option.map (mkExpr creationAide)
1299+
1300+
let itemRange =
1301+
match parenArg with
1302+
| Some pa -> unionRanges memberExpr.Range pa.Range
1303+
| None -> memberExpr.Range
1304+
1305+
ExprDynamicChainItemNode(memberExpr', parenArg', itemRange))
1306+
1307+
ExprDynamicChainNode(mkExpr creationAide leading, chainItems, exprRange)
1308+
|> Expr.DynamicChain
12571309
| SynExpr.Dynamic(funcExpr, _, argExpr, _) ->
12581310
ExprDynamicNode(mkExpr creationAide funcExpr, mkExpr creationAide argExpr, exprRange)
12591311
|> Expr.Dynamic

src/Fantomas.Core/CodePrinter.fs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ let rec (|UppercaseExpr|LowercaseExpr|) (expr: Expr) =
6666
| Expr.DotIndexedGet node -> (|UppercaseExpr|LowercaseExpr|) node.ObjectExpr
6767
| Expr.TypeApp node -> (|UppercaseExpr|LowercaseExpr|) node.Identifier
6868
| Expr.Dynamic node -> (|UppercaseExpr|LowercaseExpr|) node.FuncExpr
69+
| Expr.DynamicChain node -> (|UppercaseExpr|LowercaseExpr|) node.LeadingExpr
6970
| Expr.AppLongIdentAndSingleParenArg node -> lastFragmentInList node.FunctionName
7071
| Expr.AppSingleParenArg node -> (|UppercaseExpr|LowercaseExpr|) node.FunctionExpr
7172
| Expr.Paren node -> (|UppercaseExpr|LowercaseExpr|) node.Expr
@@ -772,6 +773,18 @@ let genExpr (e: Expr) =
772773
| _ -> genExpr node.FuncExpr
773774

774775
genFuncExpr +> !-"?" +> genExpr node.ArgExpr |> genNode node
776+
| Expr.DynamicChain node ->
777+
// A chain of `?` accesses is printed tight (no space before paren args).
778+
// Adding a space changes the parsing of the next `?member`. See #3159.
779+
let genItem (item: ExprDynamicChainItemNode) =
780+
!-"?"
781+
+> genExpr item.MemberExpr
782+
+> (match item.ParenArg with
783+
| Some arg -> genExpr arg
784+
| None -> sepNone)
785+
|> genNode item
786+
787+
genExpr node.LeadingExpr +> col sepNone node.Items genItem |> genNode node
775788
| Expr.PrefixApp node ->
776789
let genWithoutSpace = genSingleTextNode node.Operator +> genExpr node.Expr
777790
let genWithSpace = genSingleTextNode node.Operator +> sepSpace +> genExpr node.Expr

src/Fantomas.Core/Selection.fs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,9 @@ let mkTreeWithSingleNode (node: Node) : TreeForSelection =
219219
| :? ExprDynamicNode as node ->
220220
let expr = Expr.Dynamic node
221221
mkOakFromModuleDecl (ModuleDecl.DeclExpr expr)
222+
| :? ExprDynamicChainNode as node ->
223+
let expr = Expr.DynamicChain node
224+
mkOakFromModuleDecl (ModuleDecl.DeclExpr expr)
222225
| :? ExprPrefixAppNode as node ->
223226
let expr = Expr.PrefixApp node
224227
mkOakFromModuleDecl (ModuleDecl.DeclExpr expr)

src/Fantomas.Core/SyntaxOak.fs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1314,6 +1314,27 @@ type ExprDynamicNode(funcExpr: Expr, argExpr: Expr, range) =
13141314
member val FuncExpr = funcExpr
13151315
member val ArgExpr = argExpr
13161316

1317+
/// A single `?member` (with optional paren or unit argument) inside a <see cref="ExprDynamicChainNode"/>.
1318+
type ExprDynamicChainItemNode(memberExpr: Expr, parenArg: Expr option, range) =
1319+
inherit NodeBase(range)
1320+
1321+
override val Children: Node array = [| yield Expr.Node memberExpr; yield! noa (Option.map Expr.Node parenArg) |]
1322+
1323+
member val MemberExpr = memberExpr
1324+
member val ParenArg = parenArg
1325+
1326+
/// Example: `x?a("")?b(t)` — a chain of two or more `?` operator accesses.
1327+
/// Captured as a dedicated node so the printer can keep `?member(arg)` tight,
1328+
/// because adding a space before the paren argument changes parsing of the
1329+
/// following `?member`. See #3159.
1330+
type ExprDynamicChainNode(leadingExpr: Expr, items: ExprDynamicChainItemNode list, range) =
1331+
inherit NodeBase(range)
1332+
1333+
override val Children: Node array = [| yield Expr.Node leadingExpr; yield! nodes items |]
1334+
1335+
member val LeadingExpr = leadingExpr
1336+
member val Items = items
1337+
13171338
/// Example: `!x`, `-x`, `~~~x` — a prefix (unary) operator applied to an expression.
13181339
type ExprPrefixAppNode(operator: SingleTextNode, expr: Expr, range) =
13191340
inherit NodeBase(range)
@@ -1915,6 +1936,7 @@ type Expr =
19151936
| ParenFunctionNameWithStar of ExprParenFunctionNameWithStarNode
19161937
| Paren of ExprParenNode
19171938
| Dynamic of ExprDynamicNode
1939+
| DynamicChain of ExprDynamicChainNode
19181940
| PrefixApp of ExprPrefixAppNode
19191941
| SameInfixApps of ExprSameInfixAppsNode
19201942
| InfixApp of ExprInfixAppNode
@@ -1983,6 +2005,7 @@ type Expr =
19832005
| ParenFunctionNameWithStar n -> n
19842006
| Paren n -> n
19852007
| Dynamic n -> n
2008+
| DynamicChain n -> n
19862009
| PrefixApp n -> n
19872010
| SameInfixApps n -> n
19882011
| InfixApp n -> n

0 commit comments

Comments
 (0)