From 25118e0b6e6e6a6fedc133eb2c6273324e5710a0 Mon Sep 17 00:00:00 2001 From: Mangel Maxime Date: Tue, 27 Oct 2026 10:08:12 +0100 Subject: [PATCH] feat(all): error on unsupported RegexOptions at compile time --- src/Fable.Transforms/Beam/Replacements.fs | 14 ++- src/Fable.Transforms/Dart/Replacements.fs | 12 +++ src/Fable.Transforms/Python/Replacements.fs | 12 +++ src/Fable.Transforms/Replacements.Util.fs | 99 +++++++++++++++++-- src/Fable.Transforms/Replacements.fs | 12 +++ src/Fable.Transforms/Rust/Replacements.fs | 13 +++ src/Fable.Transforms/Transforms.Util.fs | 2 + src/fable-library-beam/fable_regex.erl | 4 + src/fable-library-py/fable_library/reg_exp.py | 4 + src/fable-library-py/tests/test_reg_exp.py | 18 ++++ .../Compiler/Fable.Tests.Compiler.fsproj | 1 + tests/Integration/Compiler/Main.fs | 1 + .../Integration/Compiler/RegexOptionsTests.fs | 56 +++++++++++ 13 files changed, 241 insertions(+), 7 deletions(-) create mode 100644 src/fable-library-py/tests/test_reg_exp.py create mode 100644 tests/Integration/Compiler/RegexOptionsTests.fs diff --git a/src/Fable.Transforms/Beam/Replacements.fs b/src/Fable.Transforms/Beam/Replacements.fs index 563878c5db..c0f298fca7 100644 --- a/src/Fable.Transforms/Beam/Replacements.fs +++ b/src/Fable.Transforms/Beam/Replacements.fs @@ -3516,13 +3516,25 @@ let mailbox (com: ICompiler) (_ctx: Context) r t (i: CallInfo) (thisArg: Expr op let private regex (com: ICompiler) - (_ctx: Context) + (ctx: Context) r (t: Type) (info: CallInfo) (thisArg: Expr option) (args: Expr list) = + checkRegexOptions + com + ctx + r + (RegexOption.IgnoreCase + ||| RegexOption.Multiline + ||| RegexOption.Compiled + ||| RegexOption.Singleline + ||| RegexOption.ECMAScript + ||| RegexOption.NonBacktracking) + args + match info.CompiledName, thisArg, args with // Constructor | ".ctor", _, [ pattern ] -> Helper.LibCall(com, "fable_regex", "create", t, [ pattern ], ?loc = r) |> Some diff --git a/src/Fable.Transforms/Dart/Replacements.fs b/src/Fable.Transforms/Dart/Replacements.fs index e1ebdc750b..4811ae52b5 100644 --- a/src/Fable.Transforms/Dart/Replacements.fs +++ b/src/Fable.Transforms/Dart/Replacements.fs @@ -3309,6 +3309,18 @@ let regexMatchToSeq com t e = Helper.LibCall(com, "RegExp", "GroupIterable", t, [ e ]) let regex com (ctx: Context) r t (i: CallInfo) (thisArg: Expr option) (args: Expr list) = + checkRegexOptions + com + ctx + r + (RegexOption.IgnoreCase + ||| RegexOption.Multiline + ||| RegexOption.Compiled + ||| RegexOption.Singleline + ||| RegexOption.ECMAScript + ||| RegexOption.NonBacktracking) + args + let isGroup = function | ExprType(DeclaredTypeFullName Types.regexGroup) -> true diff --git a/src/Fable.Transforms/Python/Replacements.fs b/src/Fable.Transforms/Python/Replacements.fs index b3bc605756..694e9c69c7 100644 --- a/src/Fable.Transforms/Python/Replacements.fs +++ b/src/Fable.Transforms/Python/Replacements.fs @@ -3513,6 +3513,18 @@ let regex com (ctx: Context) r t (i: CallInfo) (thisArg: Expr option) (args: Exp let propInt p callee = getExpr r t callee (makeIntConst p) let propStr p callee = getExpr r t callee (makeStrConst p) + checkRegexOptions + com + ctx + r + (RegexOption.IgnoreCase + ||| RegexOption.Multiline + ||| RegexOption.Compiled + ||| RegexOption.Singleline + ||| RegexOption.ECMAScript + ||| RegexOption.NonBacktracking) + args + let isGroup = match thisArg with | Some(ExprType(DeclaredTypeFullName Types.regexGroup)) -> true diff --git a/src/Fable.Transforms/Replacements.Util.fs b/src/Fable.Transforms/Replacements.Util.fs index 89fbe6143d..2c30d27c82 100644 --- a/src/Fable.Transforms/Replacements.Util.fs +++ b/src/Fable.Transforms/Replacements.Util.fs @@ -1282,18 +1282,63 @@ let (|CustomOp|_|) (com: ICompiler) (ctx: Context) r t opName (argExprs: Expr li | Some x -> ValueSome x | None -> ValueNone +module RegexOption = + [] + let IgnoreCase = 1 + + [] + let Multiline = 2 + + [] + let ExplicitCapture = 4 + + [] + let Compiled = 8 + + [] + let Singleline = 16 + + [] + let IgnorePatternWhitespace = 32 + + [] + let RightToLeft = 64 + + [] + let ECMAScript = 256 + + [] + let CultureInvariant = 512 + + [] + let NonBacktracking = 1024 + + let names = + [ + IgnoreCase, "IgnoreCase" + Multiline, "Multiline" + ExplicitCapture, "ExplicitCapture" + Compiled, "Compiled" + Singleline, "Singleline" + IgnorePatternWhitespace, "IgnorePatternWhitespace" + RightToLeft, "RightToLeft" + ECMAScript, "ECMAScript" + CultureInvariant, "CultureInvariant" + NonBacktracking, "NonBacktracking" + ] + [] let (|RegexFlags|_|) e = let rec getFlags = function | NumberConst(NumberValue.Int32 value, _) -> match value with - | 1 -> Some [ RegexIgnoreCase ] - | 2 -> Some [ RegexMultiline ] - | 8 -> Some [] // Compiled flag (ignored) - | 16 -> Some [ RegexSingleline ] - | 256 -> Some [] // ECMAScript flag (ignored) - | 1024 -> Some [] // NonBacktracking flag (ignored) + | RegexOption.IgnoreCase -> Some [ RegexIgnoreCase ] + | RegexOption.Multiline -> Some [ RegexMultiline ] + | RegexOption.Compiled -> Some [] + | RegexOption.Singleline -> Some [ RegexSingleline ] + | RegexOption.ECMAScript -> Some [] + | RegexOption.NonBacktracking -> Some [] | _ -> None | Operation(Binary(BinaryOrBitwise, flags1, flags2), _, _, _) -> match getFlags flags1, getFlags flags2 with @@ -1305,6 +1350,48 @@ let (|RegexFlags|_|) e = | Some x -> ValueSome x | None -> ValueNone +[] +let (|RegexOptionsConst|_|) (e: Expr) = + let rec fold = + function + | NumberConst(NumberValue.Int32 value, _) -> Some value + | Operation(Binary(BinaryOrBitwise, left, right), _, _, _) -> + match fold left, fold right with + | Some left, Some right -> Some(left ||| right) + | _ -> None + | _ -> None + + match e.Type, fold e with + | Number(Int32, NumberInfo.IsEnum ent), Some value when ent.FullName = Types.regexOptions -> ValueSome value + | _ -> ValueNone + +let checkRegexOptions (com: ICompiler) (ctx: Context) r (supported: int) (args: Expr list) = + for arg in args do + match arg with + | RegexOptionsConst options when options &&& ~~~supported <> 0 -> + let unsupported = options &&& ~~~supported + + let names = + RegexOption.names + |> List.choose (fun (bit, name) -> + if unsupported &&& bit <> 0 then + Some $"RegexOptions.%s{name}" + else + None + ) + + let names = + if List.isEmpty names then + [ $"RegexOptions value 0x%X{unsupported}" ] + else + names + + let names = String.concat ", " names + + $"%s{names} is not supported by Fable on this target" + |> addError com ctx.InlinePath r + | _ -> () + [] let (|UniversalFableCoreHelpers|_|) (com: ICompiler) (ctx: Context) r t (i: CallInfo) args error = function diff --git a/src/Fable.Transforms/Replacements.fs b/src/Fable.Transforms/Replacements.fs index 2852342cd7..2ede8569ea 100644 --- a/src/Fable.Transforms/Replacements.fs +++ b/src/Fable.Transforms/Replacements.fs @@ -4055,6 +4055,18 @@ let regex com (ctx: Context) r t (i: CallInfo) (thisArg: Expr option) (args: Exp let propInt p callee = getExpr r t callee (makeIntConst p) let propStr p callee = getExpr r t callee (makeStrConst p) + checkRegexOptions + com + ctx + r + (RegexOption.IgnoreCase + ||| RegexOption.Multiline + ||| RegexOption.Compiled + ||| RegexOption.Singleline + ||| RegexOption.ECMAScript + ||| RegexOption.NonBacktracking) + args + let isGroup = match thisArg with | Some(ExprType(DeclaredTypeFullName Types.regexGroup)) -> true diff --git a/src/Fable.Transforms/Rust/Replacements.fs b/src/Fable.Transforms/Rust/Replacements.fs index f02f077db3..3e36ebafd7 100644 --- a/src/Fable.Transforms/Rust/Replacements.fs +++ b/src/Fable.Transforms/Rust/Replacements.fs @@ -2968,6 +2968,19 @@ let bclNativeImpl com ctx r t i moduleName memberName (thisArg: Expr option) (ar | None -> makeStaticMemberCall com r t i moduleName memberName args let regex com (ctx: Context) r t (i: CallInfo) (thisArg: Expr option) (args: Expr list) = + checkRegexOptions + com + ctx + r + (RegexOption.IgnoreCase + ||| RegexOption.Multiline + ||| RegexOption.Compiled + ||| RegexOption.Singleline + ||| RegexOption.IgnorePatternWhitespace + ||| RegexOption.ECMAScript + ||| RegexOption.NonBacktracking) + args + match i.CompiledName with // | "GetEnumerator" -> getEnumerator com r t i thisArg.Value |> Some | meth -> diff --git a/src/Fable.Transforms/Transforms.Util.fs b/src/Fable.Transforms/Transforms.Util.fs index 1572a850d8..597dc64321 100644 --- a/src/Fable.Transforms/Transforms.Util.fs +++ b/src/Fable.Transforms/Transforms.Util.fs @@ -312,6 +312,8 @@ module Types = [] let regexCaptureCollection = "System.Text.RegularExpressions.CaptureCollection" + let regexOptions = "System.Text.RegularExpressions.RegexOptions" + [] let random = "System.Random" diff --git a/src/fable-library-beam/fable_regex.erl b/src/fable-library-beam/fable_regex.erl index f51e10b7ca..e01420008a 100644 --- a/src/fable-library-beam/fable_regex.erl +++ b/src/fable-library-beam/fable_regex.erl @@ -450,6 +450,10 @@ get_options(#{options := O}) -> O. %% --- Internal helpers --- +options_to_erl(Options) when is_integer(Options), Options band bnot (1 bor 2 bor 8 bor 16 bor 256 bor 1024) =/= 0 -> + erlang:error( + <<"RegexOptions only supports: IgnoreCase, Multiline, Compiled, Singleline, ECMAScript and NonBacktracking">> + ); options_to_erl(Options) when is_integer(Options) -> Flags = [], %% IgnoreCase diff --git a/src/fable-library-py/fable_library/reg_exp.py b/src/fable-library-py/fable_library/reg_exp.py index aef8cc9f99..982731b75a 100644 --- a/src/fable-library-py/fable_library/reg_exp.py +++ b/src/fable-library-py/fable_library/reg_exp.py @@ -58,6 +58,10 @@ def GetEnumerator(self, __unit: Unit = UNIT) -> IEnumerator[Match[str]]: def _options_to_flags(options: int) -> int: """Convert .NET options to Python flags.""" + if options & ~(1 | 2 | 8 | 16 | 256 | 1024): + raise Exception( + "RegexOptions only supports: IgnoreCase, Multiline, Compiled, Singleline, ECMAScript and NonBacktracking" + ) flags = 256 if options & 1: flags |= re.IGNORECASE diff --git a/src/fable-library-py/tests/test_reg_exp.py b/src/fable-library-py/tests/test_reg_exp.py new file mode 100644 index 0000000000..0f15d2e01b --- /dev/null +++ b/src/fable-library-py/tests/test_reg_exp.py @@ -0,0 +1,18 @@ +import pytest +from fable_library.reg_exp import create + + +def test_accepted_options_compile() -> None: + ignore_case = 1 + compiled = 8 + non_backtracking = 1024 + regex = create("ab", ignore_case | compiled | non_backtracking) + + assert regex.search("xABx") is not None + + +def test_unsupported_option_raises() -> None: + right_to_left = 64 + + with pytest.raises(Exception, match="RegexOptions only supports"): + create("ab", right_to_left) diff --git a/tests/Integration/Compiler/Fable.Tests.Compiler.fsproj b/tests/Integration/Compiler/Fable.Tests.Compiler.fsproj index d2c9455820..ab4f65a052 100644 --- a/tests/Integration/Compiler/Fable.Tests.Compiler.fsproj +++ b/tests/Integration/Compiler/Fable.Tests.Compiler.fsproj @@ -31,6 +31,7 @@ + diff --git a/tests/Integration/Compiler/Main.fs b/tests/Integration/Compiler/Main.fs index 83b65a0009..86f7f71ef4 100644 --- a/tests/Integration/Compiler/Main.fs +++ b/tests/Integration/Compiler/Main.fs @@ -14,6 +14,7 @@ let allTests = CompilerHelpers.tests Inflate.tests WatchDependency.tests + RegexOptions.tests ] diff --git a/tests/Integration/Compiler/RegexOptionsTests.fs b/tests/Integration/Compiler/RegexOptionsTests.fs new file mode 100644 index 0000000000..0924e9fb36 --- /dev/null +++ b/tests/Integration/Compiler/RegexOptionsTests.fs @@ -0,0 +1,56 @@ +module Fable.Tests.Compiler.RegexOptions + +open Util.Testing +open Fable.Tests.Compiler.Util +open Fable.Tests.Compiler.Util.Compiler + +let private compile source = Compiler.Cached.compile Compiler.Settings.standard source + +let tests = + testList "Regex Options" [ + testCase "An unsupported option on the constructor is a compile error" <| fun _ -> + let source = + """ +open System.Text.RegularExpressions + +let r = Regex("a", RegexOptions.RightToLeft) +""" + compile source + |> Assert.Is.error + |> Assert.Exists.errorWith "RegexOptions.RightToLeft is not supported" + |> ignore + + testCase "Only the unsupported options of a combination are reported" <| fun _ -> + let source = + """ +open System.Text.RegularExpressions + +let found = Regex.IsMatch("abc", "b", RegexOptions.IgnoreCase ||| RegexOptions.ExplicitCapture ||| RegexOptions.CultureInvariant) +""" + compile source + |> Assert.Exists.errorWith "RegexOptions.ExplicitCapture, RegexOptions.CultureInvariant is not supported" + |> ignore + + testCase "Options that only affect performance compile without a diagnostic" <| fun _ -> + let source = + """ +open System.Text.RegularExpressions + +let r = Regex("a", RegexOptions.Compiled ||| RegexOptions.NonBacktracking ||| RegexOptions.IgnoreCase) +let parts = Regex.Split("a1b", "\\d", RegexOptions.ECMAScript) +""" + compile source + |> Assert.Is.strictSuccess + |> ignore + + testCase "Options that are only known at run time are left to the runtime check" <| fun _ -> + let source = + """ +open System.Text.RegularExpressions + +let make (options: RegexOptions) = Regex("a", options) +""" + compile source + |> Assert.Is.strictSuccess + |> ignore + ]