Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/Fable.Cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Fixed

* [Python] Fix `nonlocal`/`global` declarations generated inside `match/case` bodies causing `SyntaxError` (by @dbrattli)

## 5.0.0-rc.2 - 2026-03-03

### Added
Expand Down
4 changes: 4 additions & 0 deletions src/Fable.Compiler/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Fixed

* [Python] Fix `nonlocal`/`global` declarations generated inside `match/case` bodies causing `SyntaxError` (by @dbrattli)

## 5.0.0-rc.2 - 2026-03-03

### Added
Expand Down
53 changes: 17 additions & 36 deletions src/Fable.Transforms/Python/Fable2Python.Transforms.fs
Original file line number Diff line number Diff line change
Expand Up @@ -1182,33 +1182,6 @@ let transformCurriedApplyAsStatements com ctx range t returnStrategy callee args
let expr, stmts = transformCurriedApply com ctx range callee args
stmts @ (expr |> resolveExpr ctx t returnStrategy)

let getNonLocals ctx (body: Statement list) =
let body, nonLocals =
body
|> List.partition (
function
| Statement.NonLocal _
| Statement.Global _ -> false
| _ -> true
)

let nonLocal =
nonLocals
|> List.collect (
function
| Statement.NonLocal nl -> nl.Names
| Statement.Global gl -> gl.Names
| _ -> []
)
|> List.distinct
|> (fun names ->
match ctx.BoundVars.Inceptions with
| 1 -> Statement.global' names
| _ -> Statement.nonLocal names
)

[ nonLocal ], body

let transformBody (_com: IPythonCompiler) ctx _ret (body: Statement list) : Statement list =
match body with
| [] -> [ Pass ]
Expand Down Expand Up @@ -1927,8 +1900,9 @@ let private transformDebugModeGuardPatternAsMatch
let defaultCase = buildDefaultMatchCase com ctx returnStrategy targets defaultIndex
let allCases = matchCases @ [ defaultCase ]
let subjectExpr, stmts = com.TransformAsExpr(ctx, Fable.IdentExpr subjectIdent)
let liftedNonLocals, allCases = allCases |> getNonLocalsFromMatchCases ctx
let matchStmt = Statement.match' (subjectExpr, allCases)
Some(stmts @ [ matchStmt ])
Some(stmts @ liftedNonLocals @ [ matchStmt ])

/// Transforms release mode (inlined) guard pattern cases into a Python match statement.
let private transformReleaseModeGuardPatternAsMatch
Expand All @@ -1954,8 +1928,9 @@ let private transformReleaseModeGuardPatternAsMatch
let defaultCase = buildDefaultMatchCase com ctx returnStrategy targets defaultIndex
let allCases = matchCases @ [ defaultCase ]
let subjectExpr, stmts = com.TransformAsExpr(ctx, Fable.IdentExpr subjectIdent)
let liftedNonLocals, allCases = allCases |> getNonLocalsFromMatchCases ctx
let matchStmt = Statement.match' (subjectExpr, allCases)
Some(stmts @ [ matchStmt ])
Some(stmts @ liftedNonLocals @ [ matchStmt ])

/// Transforms switch-like pattern cases into a Python match statement.
let private transformSwitchPatternAsMatch
Expand Down Expand Up @@ -2029,10 +2004,11 @@ let private transformSwitchPatternAsMatch
// Transform the evaluation expression
let subject, stmts = com.TransformAsExpr(ctx, evalExpr)

// Build the match statement
// Build the match statement, lifting nonlocals out of case bodies
let liftedNonLocals, allCases = allCases |> getNonLocalsFromMatchCases ctx
let matchStmt = Statement.match' (subject, allCases)

Some(stmts @ [ matchStmt ])
Some(stmts @ liftedNonLocals @ [ matchStmt ])

/// Transforms a tuple Option pattern into a Python match statement.
/// Generates: match (x, y): case (None, _): ... case (_, None): ... case _: ...
Expand Down Expand Up @@ -2085,11 +2061,12 @@ let private transformTupleOptionPatternAsMatch
let successBody = Util.ensureNonEmptyBody (bindingStmts @ successBodyStmts)
let successCase = MatchCase.matchCase (Pattern.matchWildcard (), successBody)

// Combine all cases
// Combine all cases, lifting nonlocals out of case bodies
let allCases = noneCases @ [ successCase ]
let liftedNonLocals, allCases = allCases |> getNonLocalsFromMatchCases ctx
let matchStmt = Statement.match' (subject, allCases)

Some(subjectStmts @ [ matchStmt ])
Some(subjectStmts @ liftedNonLocals @ [ matchStmt ])

/// Transform a tuple boolean+guard pattern into a Python match statement.
/// Handles patterns like: match tuple with | true, _, i when i > -1 -> ... | _ -> ...
Expand Down Expand Up @@ -2181,8 +2158,10 @@ let private transformTupleBoolGuardPatternAsMatch
let defaultBodyStmts = com.TransformAsStatements(ctx, returnStrategy, defaultExpr)
let defaultBody = Util.ensureNonEmptyBody (defaultBindingStmts @ defaultBodyStmts)
let defaultCase = MatchCase.matchCase (Pattern.matchWildcard (), defaultBody)
let matchStmt = Statement.match' (tupleExpr, matchCases @ [ defaultCase ])
Some(tupleStmts @ [ matchStmt ])
let allCases = matchCases @ [ defaultCase ]
let liftedNonLocals, allCases = allCases |> getNonLocalsFromMatchCases ctx
let matchStmt = Statement.match' (tupleExpr, allCases)
Some(tupleStmts @ liftedNonLocals @ [ matchStmt ])

/// Transform a decision tree into a Python match statement.
/// Returns None if the pattern is too complex for match statement conversion.
Expand Down Expand Up @@ -2890,10 +2869,12 @@ let rec transformAsStatements (com: IPythonCompiler) ctx returnStrategy (expr: F

let tagPattern = MatchValue(Expression.intConstant tag)
let wildcardPattern = Pattern.matchWildcard ()
let thenNonLocals, thenBody = getNonLocals ctx thenBody
let elseNonLocals, elseBody = getNonLocals ctx elseBody
let thenCase = MatchCase.matchCase (tagPattern, thenBody)
let elseCase = MatchCase.matchCase (wildcardPattern, elseBody)
let matchStmt = Statement.match' (subject, [ thenCase; elseCase ])
subjectStmts @ [ matchStmt ]
subjectStmts @ thenNonLocals @ elseNonLocals @ [ matchStmt ]
| Fable.IfThenElse(guardExpr, thenExpr, elseExpr, r) ->
let asStatement =
match returnStrategy with
Expand Down
41 changes: 41 additions & 0 deletions src/Fable.Transforms/Python/Fable2Python.Util.fs
Original file line number Diff line number Diff line change
Expand Up @@ -1053,6 +1053,47 @@ module Util =
[ Statement.return' (libCall com ctx None "util" "to_iterator" [ enumerator ]) ]


/// Extracts NonLocal and Global statements from a body, returning them separately.
/// The nonlocal/global declarations are consolidated into a single statement,
/// and global is used at the top inception level, nonlocal otherwise.
let getNonLocals ctx (body: Statement list) =
let body, nonLocals =
body
|> List.partition (
function
| Statement.NonLocal _
| Statement.Global _ -> false
| _ -> true
)

let nonLocal =
nonLocals
|> List.collect (
function
| Statement.NonLocal nl -> nl.Names
| Statement.Global gl -> gl.Names
| _ -> []
)
|> List.distinct
|> (fun names ->
match ctx.BoundVars.Inceptions with
| 1 -> Statement.global' names
| _ -> Statement.nonLocal names
)

[ nonLocal ], body

/// Lifts NonLocal/Global statements out of each match case body and ensures bodies are non-empty.
/// Delegates to getNonLocals for the actual partitioning logic.
let getNonLocalsFromMatchCases ctx (cases: MatchCase list) : Statement list * MatchCase list =
cases
|> List.map (fun case ->
let nonLocals, cleanBody = getNonLocals ctx case.Body
nonLocals, { case with Body = ensureNonEmptyBody cleanBody }
)
|> List.unzip
|> fun (nls, cases) -> List.concat nls, cases

/// Common utilities for Python transformations
module Helpers =
/// Returns true if the first field type can be None in Python
Expand Down
68 changes: 68 additions & 0 deletions tests/Python/TestPatternMatch.fs
Original file line number Diff line number Diff line change
Expand Up @@ -418,3 +418,71 @@ let ``test try get value pattern`` () =
tryGetValuePattern "x" data |> equal (Some 10)
tryGetValuePattern "y" data |> equal (Some 20)
tryGetValuePattern "z" data |> equal None

// ----------------------------------------------------------------------------
// 8. Nonlocal in match/case (regression test)
// Tests that mutable variables captured in closures work correctly when
// assigned inside union pattern match statements (nonlocal must be hoisted
// out of match/case blocks to avoid Python SyntaxError).
// ----------------------------------------------------------------------------

type Msg =
| OnNext of int
| OnError of exn
| OnCompleted

/// Simulates a take(n) operator: processes at most `count` OnNext messages.
let takeFn (count: int) (msgs: Msg list) : int list =
let mutable remaining = count
let results = System.Collections.Generic.List<int>()

let handle (msg: Msg) =
let remaining_snapshot = remaining
match msg with
| OnError _ -> ()
| OnCompleted -> ()
| OnNext value ->
if remaining_snapshot > 1 then
remaining <- remaining_snapshot - 1
results.Add(value)
elif remaining_snapshot = 1 then
remaining <- 0
results.Add(value)

msgs |> List.iter handle
results |> Seq.toList

[<Fact>]
let ``test nonlocal hoisted out of match case - take operator`` () =
let msgs = [ OnNext 1; OnNext 2; OnNext 3; OnNext 4; OnNext 5 ]
takeFn 3 msgs |> equal [ 1; 2; 3 ]
takeFn 1 msgs |> equal [ 1 ]
takeFn 5 msgs |> equal [ 1; 2; 3; 4; 5 ]

/// Simulates nested union pattern matching with mutable captures.
let nestedUnionMatch (msg: Msg) : string =
let mutable state = "initial"

let handle () =
let snapshot = state
match msg with
| OnError _ ->
state <- "error"
"error"
| _ ->
match msg with
| OnCompleted ->
state <- "completed"
"completed"
| _ ->
// OnNext case - uses snapshot (nonlocal must be hoisted)
state <- snapshot + "-processed"
"next:" + snapshot

handle ()

[<Fact>]
let ``test nonlocal hoisted out of nested match case`` () =
nestedUnionMatch (OnNext 42) |> equal "next:initial"
nestedUnionMatch OnCompleted |> equal "completed"
nestedUnionMatch (OnError(exn "err")) |> equal "error"
Loading