diff --git a/src/Fable.Cli/CHANGELOG.md b/src/Fable.Cli/CHANGELOG.md index c77b49eb7..730960e5d 100644 --- a/src/Fable.Cli/CHANGELOG.md +++ b/src/Fable.Cli/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * [Python] Print root module and module function comments (by @alfonsogarciacaro) * [Rust] Add support for module comments (by @ncave) * [Rust] Add support for null strings (by @ncave) +* [TS/JS] `Pojo` attribute support (by @alfonsogarciacaro) ### Fixed diff --git a/src/Fable.Compiler/CHANGELOG.md b/src/Fable.Compiler/CHANGELOG.md index de78c5f4e..c1974a58a 100644 --- a/src/Fable.Compiler/CHANGELOG.md +++ b/src/Fable.Compiler/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * [Python] - Print root module and module function comments (by @alfonsogarciacaro) * [Rust] Add support for module comments (by @ncave) * [Rust] Add support for null strings (by @ncave) +* [TS/JS] `Pojo` attribute support (by @alfonsogarciacaro) ### Fixed diff --git a/src/Fable.Core/CHANGELOG.md b/src/Fable.Core/CHANGELOG.md index 8aa8ac0b3..c2bb5eaf9 100644 --- a/src/Fable.Core/CHANGELOG.md +++ b/src/Fable.Core/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +* [TS/JS] Add `Pojo` attribute (by @alfonsogarciacaro) + ## 4.3.0 - 2024-01-25 ### Added diff --git a/src/Fable.Core/Fable.Core.JS.fs b/src/Fable.Core/Fable.Core.JS.fs index 90e383dbf..238b917bb 100644 --- a/src/Fable.Core/Fable.Core.JS.fs +++ b/src/Fable.Core/Fable.Core.JS.fs @@ -48,6 +48,32 @@ module JSX = let nothing: Element = nativeOnly module JS = + /// + /// Constructor calls to class decorated with this attribute will be compiled as JS pojos defined by the constructor arguments. + /// The class declaration will be erased, but in Typescript an interface will be generated. + /// + /// + /// + /// [<Pojo>] + /// type Person(name: string, ?age: int) = + /// member val name = name + /// member val age = age + /// + /// let p = Person("Alice") + /// + /// // Typescript + /// export interface Person { + /// name: string, + /// age?: number + /// } + /// + /// export const p: Person = { name: "Alice" } + /// + /// + [] + type PojoAttribute() = + inherit Attribute() + /// Used to remove the arguments of a surrounding function immediately calling a function decorated with this argument. /// This is convenient to represent JS patterns when a function is actually loaded lazily with a dynamic import. type RemoveSurroundingArgsAttribute() = @@ -109,17 +135,6 @@ module JS = /// that is representable as a Number value, which is approximately: /// 2.2204460492503130808472633361816 x 10‍−‍16. abstract EPSILON: float - /// - /// Returns true if passed value is finite. - /// Unlike the global isFinite, Number.isFinite doesn't forcibly convert the parameter to a - /// number. Only finite values of the type number, result in true. - /// - /// A numeric value. - abstract isFinite: number: obj -> bool - /// Returns true if the value passed is an integer, false otherwise. - /// A numeric value. - abstract isInteger: number: obj -> bool - /// /// Returns a Boolean value that indicates whether a value is the reserved value NaN (not a /// number). Unlike the global isNaN(), Number.isNaN() doesn't forcefully convert the parameter /// to a number. Only values of the type number, that are also NaN, result in true. diff --git a/src/Fable.Transforms/Dart/Fable2Dart.fs b/src/Fable.Transforms/Dart/Fable2Dart.fs index ed68b48ee..d0fe7a064 100644 --- a/src/Fable.Transforms/Dart/Fable2Dart.fs +++ b/src/Fable.Transforms/Dart/Fable2Dart.fs @@ -461,8 +461,8 @@ module Util = |> Option.map (splitNamedArgs args) |> function | None -> args, [] - | Some(args, []) -> args, [] - | Some(args, namedArgs) -> + | Some(args, None) -> args, [] + | Some(args, Some namedArgs) -> args, namedArgs |> List.choose (fun (p, v) -> diff --git a/src/Fable.Transforms/FSharp2Fable.Util.fs b/src/Fable.Transforms/FSharp2Fable.Util.fs index c59c84472..bed17301c 100644 --- a/src/Fable.Transforms/FSharp2Fable.Util.fs +++ b/src/Fable.Transforms/FSharp2Fable.Util.fs @@ -272,8 +272,6 @@ type FsMemberFunctionOrValue(m: FSharpMemberOrFunctionOrValue) = member _.Attributes = m.Attributes |> Seq.map (fun x -> FsAtt(x) :> Fable.Attribute) member _.CurriedParameterGroups = - let mutable i = -1 - let namedParamsIndex = m.Attributes |> Helpers.tryFindAttrib Atts.paramObject @@ -282,6 +280,13 @@ type FsMemberFunctionOrValue(m: FSharpMemberOrFunctionOrValue) = | Some(_, (:? int as index)) -> index | _ -> 0 ) + |> Option.orElseWith (fun () -> + m.DeclaringEntity + |> Option.bind (fun ent -> ent.Attributes |> Helpers.tryFindAttrib Atts.pojoDefinedByConsArgs) + |> Option.map (fun _ -> 0) + ) + + let mutable i = -1 m.CurriedParameterGroups |> Seq.mapToList ( @@ -821,46 +826,6 @@ module Helpers = // [] attribute on the type and[] on the constructors // so we can transform it into an interface - /// - /// Check if the entity is decorated with the Global attribute - /// and all its constructors are decorated with ParamObject attribute. - /// - /// This is used to identify classes that should be transformed into interfaces. - /// - /// - /// - /// true if the entity is a global type with all constructors as param objects, - /// false otherwise. - /// - let isParamObjectClassPattern (entity: Fable.Entity) = - let isGlobalType = - entity.Attributes |> Seq.exists (fun att -> att.Entity.FullName = Atts.global_) - - let areAllConstructorsParamObject = - entity.MembersFunctionsAndValues - |> Seq.filter _.IsConstructor - |> Seq.forall (fun memb -> - // Empty constructors are considered valid as it allows to simplify unwraping - // complex Union types - // - // [] - // [] - // type ClassWithUnion private () = - // [] - // new (stringOrNumber : string) = ClassWithUnion() - // [] - // new (stringOrNumber : int) = ClassWithUnion() - // - // Without this trick when we have a lot of U2, U3, etc. to map it is really difficult - // or verbose to craft the correct F# class. By using, an empty constructor we can - // "bypass" the F# type system. - memb.CurriedParameterGroups |> List.concat |> List.isEmpty - || memb.Attributes - |> Seq.exists (fun att -> att.Entity.FullName = Atts.paramObject) - ) - - isGlobalType && areAllConstructorsParamObject - let tryPickAttrib attFullNames (attributes: FSharpAttribute seq) = let attFullNames = Map attFullNames @@ -2046,25 +2011,10 @@ module Util = let tryGlobalOrImportedAttributes (com: Compiler) (entRef: Fable.EntityRef) (attributes: Fable.Attribute seq) = let globalRef customName = - let name = - // Custom name has precedence - match customName with - | Some name -> name - | None -> - let entity = com.GetEntity(entRef) - - // If we are generating TypeScript, and the entity is an object class pattern - // we need to use the compiled name, replacing '`' with '$' to mimic - // how Fable generates the compiled name for generic types - // I was not able to find where this is done in Fable, so I am doing it manually here - if com.Options.Language = TypeScript && isParamObjectClassPattern entity then - entity.CompiledName.Replace("`", "$") - // Otherwise, we use the display name as `Global` is often used to describe external API - // and we want to keep the original name - else - entRef.DisplayName - - name |> makeTypedIdent Fable.Any |> Fable.IdentExpr |> Some + defaultArg customName entRef.DisplayName + |> makeTypedIdent Fable.Any + |> Fable.IdentExpr + |> Some match attributes with | _ when entRef.FullName.StartsWith("Fable.Core.JS.", StringComparison.Ordinal) -> globalRef None @@ -2146,6 +2096,12 @@ module Util = | _ -> false )) + let isPojoDefinedByConsArgsEntity (entity: Fable.Entity) = + entity |> hasAttribute Atts.pojoDefinedByConsArgs + + let isPojoDefinedByConsArgsFSharpEntity (ent: FSharpEntity) = + ent.Attributes |> hasAttrib Atts.pojoDefinedByConsArgs + let isEmittedOrImportedMember (memb: FSharpMemberOrFunctionOrValue) = memb.Attributes |> Seq.exists (fun att -> @@ -2405,7 +2361,12 @@ module Util = // Don't mangle interfaces by default (for better interop) unless they have Mangle attribute | _ when ent.IsInterface -> tryMangleAttribute ent.Attributes |> Option.defaultValue false // Mangle members from abstract classes unless they are global/imported or with explicitly attached members - | _ -> not (isGlobalOrImportedFSharpEntity ent || isAttachMembersEntity com ent) + | _ -> + not ( + isGlobalOrImportedFSharpEntity ent + || isAttachMembersEntity com ent + || isPojoDefinedByConsArgsFSharpEntity ent + ) let getMangledAbstractMemberName (ent: FSharpEntity) memberName overloadHash = // TODO: Error if entity doesn't have fullName? @@ -2681,8 +2642,12 @@ module Util = let moduleOrClassExpr = match tryGlobalOrImportedFSharpEntity com e with | Some expr -> Some expr - // AttachMembers classes behave the same as global/imported classes - | None when not (com.Options.Language = Rust) && isAttachMembersEntity com e -> + // AttachMembers/Pojo classes behave + // the same as global/imported classes + | None when + com.Options.Language <> Rust + && (isAttachMembersEntity com e || isPojoDefinedByConsArgsFSharpEntity e) + -> FsEnt.Ref e |> entityIdent com |> Some | None -> None diff --git a/src/Fable.Transforms/FSharp2Fable.fs b/src/Fable.Transforms/FSharp2Fable.fs index 4cf8eec40..d8e07c19e 100644 --- a/src/Fable.Transforms/FSharp2Fable.fs +++ b/src/Fable.Transforms/FSharp2Fable.fs @@ -1486,7 +1486,7 @@ let private isIgnoredNonAttachedMember (memb: FSharpMemberOrFunctionOrValue) = ) || ( match memb.DeclaringEntity with - | Some ent -> isGlobalOrImportedFSharpEntity ent + | Some ent -> isGlobalOrImportedFSharpEntity ent || isPojoDefinedByConsArgsFSharpEntity ent | None -> false ) @@ -2044,8 +2044,7 @@ let rec private transformDeclarations (com: FableCompiler) ctx fsDecls = if (isErasedOrStringEnumEntity ent && Compiler.Language <> TypeScript) - || (isGlobalOrImportedEntity ent - && (not (Compiler.Language = TypeScript && isParamObjectClassPattern ent))) + || isGlobalOrImportedEntity ent then [] else diff --git a/src/Fable.Transforms/Fable2Babel.fs b/src/Fable.Transforms/Fable2Babel.fs index fc1bba82c..b67c31271 100644 --- a/src/Fable.Transforms/Fable2Babel.fs +++ b/src/Fable.Transforms/Fable2Babel.fs @@ -1878,7 +1878,7 @@ module Util = Expression.newExpression (classExpr, [||]) - let transformCallArgs + let transformCallArgsWithNamedArgs (com: IBabelCompiler) ctx (callInfo: Fable.CallInfo) @@ -1906,26 +1906,12 @@ module Util = paramsInfo |> Option.map (splitNamedArgs args) |> function - | None -> args, None - | Some(args, []) -> - // Detect if the method has a ParamObject attribute - // If yes and no argument is passed, pass an empty object - // See https://github.com/fable-compiler/Fable/issues/3480 - match callInfo.MemberRef with - | Some(Fable.MemberRef(_, info)) -> - let hasParamObjectAttribute = - info.AttributeFullNames - |> List.tryFind (fun attr -> attr = Atts.paramObject) - |> Option.isSome - - if hasParamObjectAttribute then - args, Some(makeJsObject []) - else - args, None - | _ -> - // Here detect empty named args - args, None - | Some(args, namedArgs) -> + | None + | Some(_, None) -> args, None + // If there are named arguments but none is passed, pass an empty object + // See https://github.com/fable-compiler/Fable/issues/3480 + | Some(args, Some []) -> args, Some(makeJsObject []) + | Some(args, Some namedArgs) -> let objArg = namedArgs |> List.choose (fun (p, v) -> @@ -1958,9 +1944,12 @@ module Util = else List.map (fun e -> com.TransformAsExpr(ctx, e)) args - match objArg with - | None -> args - | Some objArg -> args @ [ objArg ] + args, objArg + + let transformCallArgs com ctx callInfo memberInfo = + match transformCallArgsWithNamedArgs com ctx callInfo memberInfo with + | args, None -> args + | args, Some objArg -> args @ [ objArg ] let resolveExpr t strategy babelExpr : Statement = match strategy with @@ -2146,16 +2135,37 @@ module Util = transformJsxCall com ctx callee callInfo.Args memberInfo | memberInfo -> let callee = com.TransformAsExpr(ctx, callee) - let args = transformCallArgs com ctx callInfo memberInfo + + let nonNamedArgs, namedArgs = + transformCallArgsWithNamedArgs com ctx callInfo memberInfo + + let args = nonNamedArgs @ Option.toList namedArgs match callInfo.ThisArg with | None when List.contains "new" callInfo.Tags -> - let typeParamInst = - match typ with - | Fable.DeclaredType(_entRef, genArgs) -> makeTypeParamInstantiationIfTypeScript com ctx genArgs + let pojo = + match nonNamedArgs, namedArgs, memberInfo with + | [], Some namedArgs, Some memberInfo -> + match memberInfo.DeclaringEntity |> Option.bind com.TryGetEntity with + | Some e when FSharp2Fable.Util.isPojoDefinedByConsArgsEntity e -> Some namedArgs + | _ -> None | _ -> None - Expression.newExpression (callee, List.toArray args, ?typeArguments = typeParamInst, ?loc = range) + match pojo with + | Some pojo -> pojo + | None -> + let typeParamInst = + match typ with + | Fable.DeclaredType(_entRef, genArgs) -> + makeTypeParamInstantiationIfTypeScript com ctx genArgs + | _ -> None + + Expression.newExpression ( + callee, + List.toArray args, + ?typeArguments = typeParamInst, + ?loc = range + ) | None -> callFunction com ctx range callee callInfo.GenericArgs args | Some(TransformExpr com ctx thisArg) -> callFunction com ctx range callee callInfo.GenericArgs (thisArg :: args) @@ -3793,7 +3803,7 @@ module Util = declareType com ctx ent decl.Name args body baseExpr classMembers - let transformParamObjectClassPatternToInterface + let transformPojoDefinedByConsArgsToInterface (com: IBabelCompiler) ctx (ent: Fable.Entity) @@ -4224,11 +4234,11 @@ module Util = else [] | ent -> - if - Compiler.Language = TypeScript - && FSharp2Fable.Helpers.isParamObjectClassPattern ent - then - transformParamObjectClassPatternToInterface com ctx ent decl + if FSharp2Fable.Util.isPojoDefinedByConsArgsEntity ent then + if Compiler.Language = TypeScript then + transformPojoDefinedByConsArgsToInterface com ctx ent decl + else + [] else let classMembers = diff --git a/src/Fable.Transforms/Python/Fable2Python.fs b/src/Fable.Transforms/Python/Fable2Python.fs index a21d570e8..3552724e3 100644 --- a/src/Fable.Transforms/Python/Fable2Python.fs +++ b/src/Fable.Transforms/Python/Fable2Python.fs @@ -1972,8 +1972,8 @@ module Util = |> Option.map (splitNamedArgs args) |> function | None -> args, None, [] - | Some(args, []) -> args, None, [] - | Some(args, namedArgs) -> + | Some(args, None) -> args, None, [] + | Some(args, Some namedArgs) -> let objArg, stmts = namedArgs |> List.choose (fun (p, v) -> diff --git a/src/Fable.Transforms/Transforms.Util.fs b/src/Fable.Transforms/Transforms.Util.fs index db6ab8ab6..8ea7729fc 100644 --- a/src/Fable.Transforms/Transforms.Util.fs +++ b/src/Fable.Transforms/Transforms.Util.fs @@ -67,6 +67,9 @@ module Atts = [] let global_ = "Fable.Core.GlobalAttribute" // typeof.FullName + [] + let pojoDefinedByConsArgs = "Fable.Core.JS.PojoAttribute" // typeof.FullName + [] let emit = "Fable.Core.Emit" @@ -1278,12 +1281,12 @@ module AST = let splitNamedArgs (args: Expr list) (info: ParamsInfo) = match info.NamedIndex with - | None -> args, [] - | Some index when index > args.Length || index > info.Parameters.Length -> args, [] + | None -> args, None + | Some index when index > args.Length || index > info.Parameters.Length -> args, Some [] | Some index -> let args, namedArgs = List.splitAt index args let namedParams = List.skip index info.Parameters |> List.truncate namedArgs.Length - args, List.zipSafe namedParams namedArgs + args, List.zipSafe namedParams namedArgs |> Some /// Used to compare arg idents of a lambda wrapping a function call let argEquals (argIdents: Ident list) argExprs = diff --git a/src/fable-metadata/lib/Fable.Core.dll b/src/fable-metadata/lib/Fable.Core.dll index 27bb345d1..3a5f6d42f 100644 Binary files a/src/fable-metadata/lib/Fable.Core.dll and b/src/fable-metadata/lib/Fable.Core.dll differ diff --git a/tests/Js/Main/JsInteropTests.fs b/tests/Js/Main/JsInteropTests.fs index 2ca052b9a..2412db10a 100644 --- a/tests/Js/Main/JsInteropTests.fs +++ b/tests/Js/Main/JsInteropTests.fs @@ -316,55 +316,36 @@ module TaggedUnion = | [] Baz of Baz #if FABLE_COMPILER -module ParamObjectClassPattern = +module PojoDefinedByConsArgs = + open Fable.Core.JS - open Fable.Core.JsInterop + [] + type Person( name : string ) = + member val name = name - [] - [] - type Person - [] - ( name : string ) = - - member val name: string = jsNative with get, set - - [] - [] - type User - [] - ( id: int, name: string, ?age: int ) = + [] + type User( id: int, name: string, ?age: int ) = inherit Person(name) - member val id: int = jsNative with get, set - member val age: int = jsNative with get, set - member val name: string = jsNative with get, set - - [] - [] - type BaseBag<'T> - () = + member val id = id + member val age = age with get, set + member val name = name - [] + [] + type BaseBag<'T>() = new ( bag : 'T) = BaseBag() + member val bag: 'T = jsNative - member val bag: 'T = jsNative with get, set - - [] - [] - type UserBag<'ExtraData> - private () = + [] + type UserBag<'ExtraData> private () = inherit BaseBag() - - [] new ( bag : int, data: 'ExtraData, ?userId : int) = UserBag() - [] new ( bag : int, data : 'ExtraData, ?userId : Guid) = UserBag() - member val bag: int = jsNative with get, set - member val data: 'ExtraData = jsNative with get, set - member val userId: U2 option = jsNative with get, set + member val data: 'ExtraData = jsNative + member val userId: U2 option = jsNative let tests = - testList "ParamObjectClassPattern" [ + testList "PojoDefinedByConsArgs" [ testCase "does create a POJO" <| fun _ -> let user = User(1, "John") @@ -377,7 +358,7 @@ module ParamObjectClassPattern = equal user userObj - testCase "ParamObject supports downcasting" <| fun _ -> + testCase "PojoDefinedByConsArgs supports downcasting" <| fun _ -> let user = User(1, "John") let person = user :> Person @@ -389,12 +370,18 @@ module ParamObjectClassPattern = equal "Kaladin" directDowncast.name #endif - testCase "ParamObject works with generics" <| fun _ -> + testCase "PojoDefinedByConsArgs works with generics" <| fun _ -> let userBag = UserBag(42, "data", 1) equal 42 userBag.bag equal "data" userBag.data equal (Some (U2.Case1 1)) userBag.userId + + testCase "PojoDefinedByConsArgs can mutate members" <| fun _ -> + let user = User(1, "John") + user.age |> equal None + user.age <- Some 42 + user.age |> equal (Some 42) ] #endif @@ -906,6 +893,6 @@ let tests = validatePassword x |> equal "np" #if FABLE_COMPILER - ParamObjectClassPattern.tests + PojoDefinedByConsArgs.tests #endif ]