diff --git a/src/Fable.Cli/CHANGELOG.md b/src/Fable.Cli/CHANGELOG.md index 7287b2086..39b61402a 100644 --- a/src/Fable.Cli/CHANGELOG.md +++ b/src/Fable.Cli/CHANGELOG.md @@ -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 diff --git a/src/Fable.Compiler/CHANGELOG.md b/src/Fable.Compiler/CHANGELOG.md index 0c1ff25c4..fbb481dee 100644 --- a/src/Fable.Compiler/CHANGELOG.md +++ b/src/Fable.Compiler/CHANGELOG.md @@ -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 diff --git a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs index b5e21f97b..b4b611206 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs @@ -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 ] @@ -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 @@ -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 @@ -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 _: ... @@ -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 -> ... | _ -> ... @@ -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. @@ -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 diff --git a/src/Fable.Transforms/Python/Fable2Python.Util.fs b/src/Fable.Transforms/Python/Fable2Python.Util.fs index 95d3c6d1a..ce5880353 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Util.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Util.fs @@ -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 diff --git a/tests/Python/TestPatternMatch.fs b/tests/Python/TestPatternMatch.fs index ea81788db..db829b4be 100644 --- a/tests/Python/TestPatternMatch.fs +++ b/tests/Python/TestPatternMatch.fs @@ -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() + + 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 + +[] +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 () + +[] +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"