diff --git a/README.md b/README.md index 7a4ac4f..1d5ed90 100644 --- a/README.md +++ b/README.md @@ -367,6 +367,13 @@ Useful references: `Okojo.Annotations` and `Okojo.SourceGenerator` are for strongly-typed host APIs that should become JavaScript globals or generated object bindings. +Key export rules: + +- `[GenerateJsGlobals]` exports members marked with `[JsMember]`, `[JsGlobalFunction]`, or `[JsGlobalProperty]` +- `[GenerateJsObject]` exports only members marked with `[JsMember]` +- `[JsIgnoreFromGlobals]` and `[JsIgnoreFromObject]` opt a shared member out of one surface +- if you do not specify an explicit JavaScript name, the generated export lowercases the first character (`Width` -> `width`, `SumNumbers` -> `sumNumbers`) + Example shape: ```csharp @@ -377,16 +384,17 @@ using Okojo.DocGenerator.Annotations; [DocDeclaration("globals")] internal sealed partial class SketchGlobals { - [JsGlobalProperty("width")] + [JsMember] public int Width => 320; - [JsGlobalProperty("strokeWidth", Writable = true)] + [JsMember] + [JsGlobalProperty(Writable = true)] public int StrokeWidth { get; set; } = 2; - [JsGlobalFunction("background")] + [JsMember("background")] private void Background(byte r, byte g, byte b) { } - [JsGlobalFunction("sumNumbers")] + [JsMember] private int SumNumbers(ReadOnlySpan values) => values.ToArray().Sum(); } ``` @@ -408,7 +416,7 @@ Real references: ## Generated object bindings and doc annotations -`GenerateJsObjectAttribute` is for object-style bindings. `DocDeclarationAttribute` and `DocIgnoreAttribute` control declaration output. +`GenerateJsObjectAttribute` is for object-style bindings. Members are opt-in via `[JsMember]`. `DocDeclarationAttribute` and `DocIgnoreAttribute` control declaration output. ```csharp using Okojo; @@ -419,8 +427,10 @@ using Okojo.DocGenerator.Annotations; [DocDeclaration("Foo/Bar", "Docs.Shapes")] public partial class GeneratedHostBindingSample { + [JsMember] public float X { get; set; } + [JsMember] public static int SumNumbers(ReadOnlySpan values) { var sum = 0; @@ -430,8 +440,10 @@ public partial class GeneratedHostBindingSample } [DocIgnore] + [JsMember] public string Echo(string value) => $"echo:{value}"; + [JsMember] public static string DescribeJsValues(ReadOnlySpan values) { if (values.Length == 0) @@ -441,6 +453,8 @@ public partial class GeneratedHostBindingSample } ``` +The generated JavaScript names for that sample are `x`, `sumNumbers`, `echo`, and `describeJsValues` unless you override them with `[JsMember("...")]`. + Real references: - `examples/OkojoArtSandbox/GeneratedObjectSample.cs` diff --git a/examples/OkojoArtSandbox/GeneratedObjectSample.cs b/examples/OkojoArtSandbox/GeneratedObjectSample.cs index 86c977d..bc451d5 100644 --- a/examples/OkojoArtSandbox/GeneratedObjectSample.cs +++ b/examples/OkojoArtSandbox/GeneratedObjectSample.cs @@ -8,6 +8,7 @@ internal partial class GeneratedObjectSample public string Name { get; set; } = string.Empty; public int Age { get; set; } + [JsMember] public bool DoSomething() { return true; diff --git a/src/Okojo.Annotations/GenerateJsGlobalsAttribute.cs b/src/Okojo.Annotations/GenerateJsGlobalsAttribute.cs index 1236d39..49f7f28 100644 --- a/src/Okojo.Annotations/GenerateJsGlobalsAttribute.cs +++ b/src/Okojo.Annotations/GenerateJsGlobalsAttribute.cs @@ -5,4 +5,5 @@ public sealed class GenerateJsGlobalsAttribute : Attribute { public string InstallerMethodName { get; set; } = "InstallGeneratedGlobals"; public string PropertySourceMethodName { get; set; } = "GetGeneratedGlobalProperties"; + public JsMemberNaming MemberNaming { get; set; } = JsMemberNaming.LowerCamelCase; } diff --git a/src/Okojo.Annotations/GenerateJsObjectAttribute.cs b/src/Okojo.Annotations/GenerateJsObjectAttribute.cs index b51fc45..81e6c1a 100644 --- a/src/Okojo.Annotations/GenerateJsObjectAttribute.cs +++ b/src/Okojo.Annotations/GenerateJsObjectAttribute.cs @@ -3,4 +3,5 @@ namespace Okojo.Annotations; [AttributeUsage(AttributeTargets.Class, Inherited = false)] public sealed class GenerateJsObjectAttribute : Attribute { + public JsMemberNaming MemberNaming { get; set; } = JsMemberNaming.LowerCamelCase; } diff --git a/src/Okojo.Annotations/JsGlobalFunctionAttribute.cs b/src/Okojo.Annotations/JsGlobalFunctionAttribute.cs index 9562776..215914f 100644 --- a/src/Okojo.Annotations/JsGlobalFunctionAttribute.cs +++ b/src/Okojo.Annotations/JsGlobalFunctionAttribute.cs @@ -1,9 +1,9 @@ namespace Okojo.Annotations; [AttributeUsage(AttributeTargets.Method, Inherited = false)] -public sealed class JsGlobalFunctionAttribute(string name) : Attribute +public sealed class JsGlobalFunctionAttribute(string? name = null) : Attribute { - public string Name { get; } = name; + public string? Name { get; } = name; public int Length { get; set; } = -1; public bool IsConstructor { get; set; } } diff --git a/src/Okojo.Annotations/JsGlobalPropertyAttribute.cs b/src/Okojo.Annotations/JsGlobalPropertyAttribute.cs index 4c17547..1a8eef8 100644 --- a/src/Okojo.Annotations/JsGlobalPropertyAttribute.cs +++ b/src/Okojo.Annotations/JsGlobalPropertyAttribute.cs @@ -1,9 +1,9 @@ namespace Okojo.Annotations; [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] -public sealed class JsGlobalPropertyAttribute(string name) : Attribute +public sealed class JsGlobalPropertyAttribute(string? name = null) : Attribute { - public string Name { get; } = name; + public string? Name { get; } = name; public bool Writable { get; set; } public bool Enumerable { get; set; } = true; public bool Configurable { get; set; } = true; diff --git a/src/Okojo.Annotations/JsIgnoreFromGlobalsAttribute.cs b/src/Okojo.Annotations/JsIgnoreFromGlobalsAttribute.cs new file mode 100644 index 0000000..5c7e695 --- /dev/null +++ b/src/Okojo.Annotations/JsIgnoreFromGlobalsAttribute.cs @@ -0,0 +1,6 @@ +namespace Okojo.Annotations; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Field, Inherited = false)] +public sealed class JsIgnoreFromGlobalsAttribute : Attribute +{ +} diff --git a/src/Okojo.Annotations/JsIgnoreFromObjectAttribute.cs b/src/Okojo.Annotations/JsIgnoreFromObjectAttribute.cs new file mode 100644 index 0000000..d059bd3 --- /dev/null +++ b/src/Okojo.Annotations/JsIgnoreFromObjectAttribute.cs @@ -0,0 +1,6 @@ +namespace Okojo.Annotations; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Field, Inherited = false)] +public sealed class JsIgnoreFromObjectAttribute : Attribute +{ +} diff --git a/src/Okojo.Annotations/JsMemberAttribute.cs b/src/Okojo.Annotations/JsMemberAttribute.cs new file mode 100644 index 0000000..bded312 --- /dev/null +++ b/src/Okojo.Annotations/JsMemberAttribute.cs @@ -0,0 +1,7 @@ +namespace Okojo.Annotations; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Field, Inherited = false)] +public sealed class JsMemberAttribute(string? name = null) : Attribute +{ + public string? Name { get; } = name; +} diff --git a/src/Okojo.Annotations/JsMemberNaming.cs b/src/Okojo.Annotations/JsMemberNaming.cs new file mode 100644 index 0000000..ff1cf3c --- /dev/null +++ b/src/Okojo.Annotations/JsMemberNaming.cs @@ -0,0 +1,8 @@ +namespace Okojo.Annotations; + +public enum JsMemberNaming +{ + LowerCamelCase = 0, + PascalCase = 1, + AsDeclared = 2 +} diff --git a/src/Okojo.Annotations/README.md b/src/Okojo.Annotations/README.md index a0b703f..926c6c0 100644 --- a/src/Okojo.Annotations/README.md +++ b/src/Okojo.Annotations/README.md @@ -6,8 +6,11 @@ Typical attributes include: - `GenerateJsGlobalsAttribute` - `GenerateJsObjectAttribute` +- `JsMemberAttribute` - `JsGlobalFunctionAttribute` - `JsGlobalPropertyAttribute` +- `JsIgnoreFromGlobalsAttribute` +- `JsIgnoreFromObjectAttribute` ## Typical use @@ -17,16 +20,17 @@ using Okojo.Annotations; [GenerateJsGlobals] public static partial class ConsoleGlobals { - [JsGlobalFunction] - public static string hello() => "hello"; + [JsMember] + public static string Hello() => "hello"; } [GenerateJsObject] public partial class GeneratedObjectSample { + [JsMember] public string Name { get; set; } = string.Empty; - public int Age { get; set; } + [JsMember] public bool DoSomething() { return true; @@ -34,4 +38,11 @@ public partial class GeneratedObjectSample } ``` +Notes: + +- `[GenerateJsObject]` is explicit opt-in: only `[JsMember]` members are exported +- `[GenerateJsGlobals]` can use `[JsMember]` for shared naming/defaults, or `[JsGlobalFunction]` / `[JsGlobalProperty]` when you need function/property-specific options +- `[JsIgnoreFromGlobals]` and `[JsIgnoreFromObject]` let one member participate in only one export surface +- default generated JavaScript names lowercase the first character unless you provide an explicit name + Use `Okojo.SourceGenerator` when you want the Roslyn generator that turns these annotations into generated installers and export glue. diff --git a/src/Okojo.DocGenerator.Cli/Okojo.DocGenerator.Cli.csproj b/src/Okojo.DocGenerator.Cli/Okojo.DocGenerator.Cli.csproj index 8db8110..4a2e0bc 100644 --- a/src/Okojo.DocGenerator.Cli/Okojo.DocGenerator.Cli.csproj +++ b/src/Okojo.DocGenerator.Cli/Okojo.DocGenerator.Cli.csproj @@ -30,9 +30,11 @@ + + diff --git a/src/Okojo.SourceGenerator/AttributeMetadataNames.cs b/src/Okojo.SourceGenerator/AttributeMetadataNames.cs new file mode 100644 index 0000000..c610cfe --- /dev/null +++ b/src/Okojo.SourceGenerator/AttributeMetadataNames.cs @@ -0,0 +1,12 @@ +namespace Okojo.SourceGenerator; + +internal static class AttributeMetadataNames +{ + public const string GenerateJsObjectAttribute = "Okojo.Annotations.GenerateJsObjectAttribute"; + public const string GenerateJsGlobalsAttribute = "Okojo.Annotations.GenerateJsGlobalsAttribute"; + public const string JsMemberAttribute = "Okojo.Annotations.JsMemberAttribute"; + public const string JsIgnoreFromObjectAttribute = "Okojo.Annotations.JsIgnoreFromObjectAttribute"; + public const string JsIgnoreFromGlobalsAttribute = "Okojo.Annotations.JsIgnoreFromGlobalsAttribute"; + public const string JsGlobalFunctionAttribute = "Okojo.Annotations.JsGlobalFunctionAttribute"; + public const string JsGlobalPropertyAttribute = "Okojo.Annotations.JsGlobalPropertyAttribute"; +} diff --git a/src/Okojo.SourceGenerator/GenerateJsGlobalsGenerator.cs b/src/Okojo.SourceGenerator/GenerateJsGlobalsGenerator.cs index 646cfab..7ad3854 100644 --- a/src/Okojo.SourceGenerator/GenerateJsGlobalsGenerator.cs +++ b/src/Okojo.SourceGenerator/GenerateJsGlobalsGenerator.cs @@ -10,7 +10,7 @@ public sealed class GenerateJsGlobalsGenerator : IIncrementalGenerator public void Initialize(IncrementalGeneratorInitializationContext context) { var provider = context.SyntaxProvider.ForAttributeWithMetadataName( - GlobalGenerationNames.GenerateJsGlobalsAttribute, + AttributeMetadataNames.GenerateJsGlobalsAttribute, static (node, _) => node is ClassDeclarationSyntax, static (ctx, _) => GlobalExportCollector.Collect((INamedTypeSymbol)ctx.TargetSymbol)); diff --git a/src/Okojo.SourceGenerator/GenerateJsObjectGenerator.cs b/src/Okojo.SourceGenerator/GenerateJsObjectGenerator.cs index 08cf34f..1b98548 100644 --- a/src/Okojo.SourceGenerator/GenerateJsObjectGenerator.cs +++ b/src/Okojo.SourceGenerator/GenerateJsObjectGenerator.cs @@ -11,7 +11,7 @@ public sealed class GenerateJsObjectGenerator : IIncrementalGenerator public void Initialize(IncrementalGeneratorInitializationContext context) { var provider = context.SyntaxProvider.ForAttributeWithMetadataName( - "Okojo.Annotations.GenerateJsObjectAttribute", + AttributeMetadataNames.GenerateJsObjectAttribute, static (node, _) => node is ClassDeclarationSyntax, static (ctx, _) => (INamedTypeSymbol)ctx.TargetSymbol); @@ -106,10 +106,10 @@ private static void EmitMembers( switch (member.Symbol) { case IFieldSymbol field when !field.IsConst: - EmitField(sb, symbol, field); + EmitField(sb, symbol, member, field); break; case IPropertySymbol property when property.Parameters.Length == 0: - EmitProperty(sb, symbol, property); + EmitProperty(sb, symbol, member, property); break; } @@ -117,11 +117,12 @@ private static void EmitMembers( EmitMethodBinding(sb, methodGroup, isStaticGroup); } - private static void EmitField(StringBuilder sb, INamedTypeSymbol containingType, IFieldSymbol field) + private static void EmitField(StringBuilder sb, INamedTypeSymbol containingType, JsObjectMemberModel member, + IFieldSymbol field) { var fullTypeName = containingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); sb.Append(" new global::Okojo.Runtime.Interop.HostMemberBinding(\"") - .Append(field.Name) + .Append(member.Name) .Append("\", global::Okojo.Runtime.Interop.HostMemberBindingKind.Field, ") .Append(field.IsStatic ? "true" : "false"); sb.Append(", getterBody: static (in global::Okojo.Runtime.CallInfo info) => "); @@ -131,7 +132,7 @@ private static void EmitField(StringBuilder sb, INamedTypeSymbol containingType, sb.Append('.').Append(field.Name); }); - if (!field.IsReadOnly) + if (member.CanWrite) { sb.Append(", setterBody: static (in global::Okojo.Runtime.CallInfo info) => { "); AppendCallTarget(sb, fullTypeName, field.IsStatic); @@ -143,14 +144,15 @@ private static void EmitField(StringBuilder sb, INamedTypeSymbol containingType, sb.AppendLine("),"); } - private static void EmitProperty(StringBuilder sb, INamedTypeSymbol containingType, IPropertySymbol property) + private static void EmitProperty(StringBuilder sb, INamedTypeSymbol containingType, JsObjectMemberModel member, + IPropertySymbol property) { var fullTypeName = containingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); sb.Append(" new global::Okojo.Runtime.Interop.HostMemberBinding(\"") - .Append(property.Name) + .Append(member.Name) .Append("\", global::Okojo.Runtime.Interop.HostMemberBindingKind.Property, ") .Append(property.IsStatic ? "true" : "false"); - if (property.GetMethod is not null) + if (member.CanRead) { sb.Append(", getterBody: static (in global::Okojo.Runtime.CallInfo info) => "); EmitToJsValue(sb, property.Type, () => @@ -160,7 +162,7 @@ private static void EmitProperty(StringBuilder sb, INamedTypeSymbol containingTy }); } - if (property.SetMethod is not null) + if (member.CanWrite) { sb.Append(", setterBody: static (in global::Okojo.Runtime.CallInfo info) => { "); AppendCallTarget(sb, fullTypeName, property.IsStatic); @@ -215,10 +217,6 @@ private static void EmitMethodGroup( "Host function argument type mismatch.", methodGroup, true, - static x => (IMethodSymbol)x.Symbol, - static x => x.Parameters, - static x => x.Type, - static _ => false, overloadIndex => dispatcherName + "__Overload" + overloadIndex); var fullTypeName = containingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); diff --git a/src/Okojo.SourceGenerator/GlobalGeneration/CSharpGlobalInstallerEmitter.cs b/src/Okojo.SourceGenerator/GlobalGeneration/CSharpGlobalInstallerEmitter.cs index 38f8ad1..f389ec2 100644 --- a/src/Okojo.SourceGenerator/GlobalGeneration/CSharpGlobalInstallerEmitter.cs +++ b/src/Okojo.SourceGenerator/GlobalGeneration/CSharpGlobalInstallerEmitter.cs @@ -168,10 +168,6 @@ private static void EmitFunctionGroup(StringBuilder sb, INamedTypeSymbol contain "Host function argument type mismatch.", functionGroup, false, - static x => x.Symbol, - static x => x.Parameters, - static x => x.Type, - static x => x.HasDefaultValue, overloadIndex => dispatcherName + "__Overload" + overloadIndex.ToString(CultureInfo.InvariantCulture)); for (var i = 0; i < functionGroup.Overloads.Count; i++) diff --git a/src/Okojo.SourceGenerator/GlobalGeneration/GlobalExportCollector.cs b/src/Okojo.SourceGenerator/GlobalGeneration/GlobalExportCollector.cs index c578070..779cf21 100644 --- a/src/Okojo.SourceGenerator/GlobalGeneration/GlobalExportCollector.cs +++ b/src/Okojo.SourceGenerator/GlobalGeneration/GlobalExportCollector.cs @@ -6,27 +6,38 @@ internal static class GlobalExportCollector { public static GlobalTypeModel? Collect(INamedTypeSymbol symbol) { - var typeAttribute = GetAttribute(symbol, GlobalGenerationNames.GenerateJsGlobalsAttribute); + var typeAttribute = JsExportAttributeHelper.GetAttribute(symbol, AttributeMetadataNames.GenerateJsGlobalsAttribute); if (typeAttribute is null) return null; - var installerMethodName = GetNamedString(typeAttribute, "InstallerMethodName") ?? "InstallGeneratedGlobals"; + var installerMethodName = + JsExportAttributeHelper.GetNamedString(typeAttribute, "InstallerMethodName") ?? "InstallGeneratedGlobals"; var propertySourceMethodName = - GetNamedString(typeAttribute, "PropertySourceMethodName") ?? "GetGeneratedGlobalProperties"; + JsExportAttributeHelper.GetNamedString(typeAttribute, "PropertySourceMethodName") ?? + "GetGeneratedGlobalProperties"; + var memberNaming = JsExportAttributeHelper.GetMemberNaming(typeAttribute); var functions = new List(); var properties = new List(); foreach (var member in symbol.GetMembers()) { + if (JsExportAttributeHelper.HasAttribute(member, AttributeMetadataNames.JsIgnoreFromGlobalsAttribute)) + continue; + + var jsMemberAttribute = JsExportAttributeHelper.GetAttribute(member, AttributeMetadataNames.JsMemberAttribute); if (member is IMethodSymbol method) { - var functionAttribute = GetAttribute(method, GlobalGenerationNames.JsGlobalFunctionAttribute); - if (functionAttribute is null || !ShouldEmitMethod(method)) + var functionAttribute = + JsExportAttributeHelper.GetAttribute(method, AttributeMetadataNames.JsGlobalFunctionAttribute); + if ((functionAttribute is null && jsMemberAttribute is null) || !ShouldEmitMethod(method)) continue; - var name = GetConstructorString(functionAttribute, 0) ?? method.Name; - var isConstructor = GetNamedBool(functionAttribute, "IsConstructor"); - var length = GetNamedInt(functionAttribute, "Length") ?? ComputeFunctionLength(method); + var name = JsExportAttributeHelper.GetMemberName(method, memberNaming, functionAttribute, jsMemberAttribute); + var isConstructor = functionAttribute is not null && + JsExportAttributeHelper.GetNamedBool(functionAttribute, "IsConstructor"); + var length = functionAttribute is not null + ? JsExportAttributeHelper.GetNamedInt(functionAttribute, "Length") ?? ComputeFunctionLength(method) + : ComputeFunctionLength(method); var parameters = method.Parameters .Select(static p => new GlobalParameterModel( p.Name, @@ -45,37 +56,47 @@ internal static class GlobalExportCollector if (member is IPropertySymbol property && property.Parameters.Length == 0) { - var propertyAttribute = GetAttribute(property, GlobalGenerationNames.JsGlobalPropertyAttribute); - if (propertyAttribute is null) + var propertyAttribute = + JsExportAttributeHelper.GetAttribute(property, AttributeMetadataNames.JsGlobalPropertyAttribute); + if (propertyAttribute is null && jsMemberAttribute is null) continue; - var name = GetConstructorString(propertyAttribute, 0) ?? property.Name; - var writable = GetNamedBool(propertyAttribute, "Writable") && property.SetMethod is not null; + var name = JsExportAttributeHelper.GetMemberName(property, memberNaming, propertyAttribute, jsMemberAttribute); + var writable = GetWritable(propertyAttribute, jsMemberAttribute is not null, property.SetMethod is not null); properties.Add(new( name, property, property.Type, writable, - GetNamedBool(propertyAttribute, "Enumerable", true), - GetNamedBool(propertyAttribute, "Configurable", true))); + propertyAttribute is not null + ? JsExportAttributeHelper.GetNamedBool(propertyAttribute, "Enumerable", true) + : true, + propertyAttribute is not null + ? JsExportAttributeHelper.GetNamedBool(propertyAttribute, "Configurable", true) + : true)); continue; } if (member is IFieldSymbol field) { - var propertyAttribute = GetAttribute(field, GlobalGenerationNames.JsGlobalPropertyAttribute); - if (propertyAttribute is null) + var propertyAttribute = + JsExportAttributeHelper.GetAttribute(field, AttributeMetadataNames.JsGlobalPropertyAttribute); + if (propertyAttribute is null && jsMemberAttribute is null) continue; - var name = GetConstructorString(propertyAttribute, 0) ?? field.Name; - var writable = GetNamedBool(propertyAttribute, "Writable") && !field.IsReadOnly; + var name = JsExportAttributeHelper.GetMemberName(field, memberNaming, propertyAttribute, jsMemberAttribute); + var writable = GetWritable(propertyAttribute, jsMemberAttribute is not null, !field.IsReadOnly); properties.Add(new( name, field, field.Type, writable, - GetNamedBool(propertyAttribute, "Enumerable", true), - GetNamedBool(propertyAttribute, "Configurable", true))); + propertyAttribute is not null + ? JsExportAttributeHelper.GetNamedBool(propertyAttribute, "Enumerable", true) + : true, + propertyAttribute is not null + ? JsExportAttributeHelper.GetNamedBool(propertyAttribute, "Configurable", true) + : true)); } } @@ -101,31 +122,12 @@ private static int ComputeFunctionLength(IMethodSymbol method) return ParameterTypeSupport.ComputeFunctionLength(method.Parameters, true); } - private static AttributeData? GetAttribute(ISymbol symbol, string metadataName) + private static bool GetWritable(AttributeData? propertyAttribute, bool wasAnnotatedWithJsMember, bool canWrite) { - return symbol.GetAttributes().FirstOrDefault(x => x.AttributeClass?.ToDisplayString() == metadataName); - } + if (propertyAttribute is not null && + JsExportAttributeHelper.TryGetNamedBool(propertyAttribute, "Writable", out var writable)) + return writable && canWrite; - private static string? GetConstructorString(AttributeData attribute, int index) - { - return attribute.ConstructorArguments.Length > index && - attribute.ConstructorArguments[index].Value is string value - ? value - : null; - } - - private static string? GetNamedString(AttributeData attribute, string name) - { - return attribute.NamedArguments.FirstOrDefault(x => x.Key == name).Value.Value as string; - } - - private static int? GetNamedInt(AttributeData attribute, string name) - { - return attribute.NamedArguments.FirstOrDefault(x => x.Key == name).Value.Value as int?; - } - - private static bool GetNamedBool(AttributeData attribute, string name, bool fallback = false) - { - return attribute.NamedArguments.FirstOrDefault(x => x.Key == name).Value.Value as bool? ?? fallback; + return wasAnnotatedWithJsMember && canWrite; } } diff --git a/src/Okojo.SourceGenerator/JsExportAttributeHelper.cs b/src/Okojo.SourceGenerator/JsExportAttributeHelper.cs new file mode 100644 index 0000000..af426ca --- /dev/null +++ b/src/Okojo.SourceGenerator/JsExportAttributeHelper.cs @@ -0,0 +1,173 @@ +using Microsoft.CodeAnalysis; + +namespace Okojo.SourceGenerator; + +internal enum JsMemberNamingPolicy +{ + LowerCamelCase = 0, + PascalCase = 1, + AsDeclared = 2 +} + +internal static class JsExportAttributeHelper +{ + public static bool HasAttribute(ISymbol symbol, string metadataName) + { + return GetAttribute(symbol, metadataName) is not null; + } + + public static AttributeData? GetAttribute(ISymbol symbol, string metadataName) + { + foreach (var attribute in symbol.GetAttributes()) + if (attribute.AttributeClass?.ToDisplayString() == metadataName) + return attribute; + + return null; + } + + public static string GetMemberName(ISymbol symbol, JsMemberNamingPolicy naming, params AttributeData?[] attributes) + { + for (var i = 0; i < attributes.Length; i++) + { + var attribute = attributes[i]; + if (attribute is null) + continue; + + var explicitName = GetConstructorString(attribute, 0) ?? GetNamedString(attribute, "Name"); + if (explicitName is not null) + return explicitName; + } + + return ApplyMemberNaming(symbol.Name, naming); + } + + public static JsMemberNamingPolicy GetMemberNaming(AttributeData? attribute) + { + if (attribute is null) + return JsMemberNamingPolicy.LowerCamelCase; + + foreach (var pair in attribute.NamedArguments) + if (pair.Key == "MemberNaming" && TryGetEnumValue(pair.Value.Value, out var value)) + return value switch + { + 1 => JsMemberNamingPolicy.PascalCase, + 2 => JsMemberNamingPolicy.AsDeclared, + _ => JsMemberNamingPolicy.LowerCamelCase + }; + + return JsMemberNamingPolicy.LowerCamelCase; + } + + public static string? GetConstructorString(AttributeData attribute, int index) + { + if (attribute.ConstructorArguments.Length <= index) + return null; + + return NormalizeString(attribute.ConstructorArguments[index].Value as string); + } + + public static string? GetNamedString(AttributeData attribute, string name) + { + foreach (var pair in attribute.NamedArguments) + if (pair.Key == name) + return NormalizeString(pair.Value.Value as string); + + return null; + } + + public static int? GetNamedInt(AttributeData attribute, string name) + { + foreach (var pair in attribute.NamedArguments) + if (pair.Key == name && pair.Value.Value is int value) + return value; + + return null; + } + + public static bool GetNamedBool(AttributeData attribute, string name, bool fallback = false) + { + return TryGetNamedBool(attribute, name, out var value) ? value : fallback; + } + + public static bool TryGetNamedBool(AttributeData attribute, string name, out bool value) + { + foreach (var pair in attribute.NamedArguments) + if (pair.Key == name && pair.Value.Value is bool boolValue) + { + value = boolValue; + return true; + } + + value = default; + return false; + } + + public static string ApplyMemberNaming(string name, JsMemberNamingPolicy naming) + { + return naming switch + { + JsMemberNamingPolicy.PascalCase => ToPascalCase(name), + JsMemberNamingPolicy.AsDeclared => name, + _ => ToLowerCamelCase(name) + }; + } + + private static string? NormalizeString(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + private static string ToLowerCamelCase(string name) + { + if (name.Length == 0 || !char.IsUpper(name[0])) + return name; + if (name.Length == 1) + return char.ToLowerInvariant(name[0]).ToString(); + + return char.ToLowerInvariant(name[0]) + name.Substring(1); + } + + private static string ToPascalCase(string name) + { + if (name.Length == 0 || !char.IsLower(name[0])) + return name; + if (name.Length == 1) + return char.ToUpperInvariant(name[0]).ToString(); + + return char.ToUpperInvariant(name[0]) + name.Substring(1); + } + + private static bool TryGetEnumValue(object? value, out int enumValue) + { + switch (value) + { + case byte byteValue: + enumValue = byteValue; + return true; + case sbyte sbyteValue: + enumValue = sbyteValue; + return true; + case short shortValue: + enumValue = shortValue; + return true; + case ushort ushortValue: + enumValue = ushortValue; + return true; + case int intValue: + enumValue = intValue; + return true; + case uint uintValue when uintValue <= int.MaxValue: + enumValue = (int)uintValue; + return true; + case long longValue when longValue is >= int.MinValue and <= int.MaxValue: + enumValue = (int)longValue; + return true; + case ulong ulongValue when ulongValue <= int.MaxValue: + enumValue = (int)ulongValue; + return true; + } + + enumValue = default; + return false; + } +} diff --git a/src/Okojo.SourceGenerator/ObjectGeneration/JsObjectExportCollector.cs b/src/Okojo.SourceGenerator/ObjectGeneration/JsObjectExportCollector.cs index f6dce49..fe3e019 100644 --- a/src/Okojo.SourceGenerator/ObjectGeneration/JsObjectExportCollector.cs +++ b/src/Okojo.SourceGenerator/ObjectGeneration/JsObjectExportCollector.cs @@ -6,23 +6,31 @@ internal static class JsObjectExportCollector { public static JsObjectTypeModel? Collect(INamedTypeSymbol symbol) { - if (!HasGenerateJsObjectAttribute(symbol)) + var typeAttribute = JsExportAttributeHelper.GetAttribute(symbol, AttributeMetadataNames.GenerateJsObjectAttribute); + if (typeAttribute is null) return null; + var memberNaming = JsExportAttributeHelper.GetMemberNaming(typeAttribute); var instanceMembers = new List(); var staticMembers = new List(); foreach (var member in symbol.GetMembers()) { - if (member.DeclaredAccessibility != Accessibility.Public) - continue; if (IsGeneratedSourceMember(member)) continue; + if (JsExportAttributeHelper.HasAttribute(member, AttributeMetadataNames.JsIgnoreFromObjectAttribute)) + continue; + + var jsMemberAttribute = JsExportAttributeHelper.GetAttribute(member, AttributeMetadataNames.JsMemberAttribute); + if (jsMemberAttribute is null) + continue; + + var jsName = JsExportAttributeHelper.GetMemberName(member, memberNaming, jsMemberAttribute); var model = member switch { IFieldSymbol field when !field.IsConst => new( - field.Name, + jsName, JsObjectMemberKind.Field, field.IsStatic, field, @@ -30,7 +38,7 @@ internal static class JsObjectExportCollector true, !field.IsReadOnly), IPropertySymbol property when property.Parameters.Length == 0 => new( - property.Name, + jsName, JsObjectMemberKind.Property, property.IsStatic, property, @@ -38,7 +46,7 @@ internal static class JsObjectExportCollector property.GetMethod is not null, property.SetMethod is not null), IMethodSymbol method when ShouldEmitMethod(method) => new JsObjectMemberModel( - method.Name, + jsName, JsObjectMemberKind.Method, method.IsStatic, method, @@ -77,15 +85,6 @@ public static bool ShouldEmitMethod(IMethodSymbol method) return true; } - private static bool HasGenerateJsObjectAttribute(INamedTypeSymbol symbol) - { - foreach (var attribute in symbol.GetAttributes()) - if (attribute.AttributeClass?.ToDisplayString() == "Okojo.Annotations.GenerateJsObjectAttribute") - return true; - - return false; - } - private static bool IsGeneratedSourceMember(ISymbol member) { var sawSourceLocation = false; diff --git a/src/Okojo.SourceGenerator/OverloadGeneration/MethodOverloadDispatchEmitter.cs b/src/Okojo.SourceGenerator/OverloadGeneration/MethodOverloadDispatchEmitter.cs index 2a04003..961c195 100644 --- a/src/Okojo.SourceGenerator/OverloadGeneration/MethodOverloadDispatchEmitter.cs +++ b/src/Okojo.SourceGenerator/OverloadGeneration/MethodOverloadDispatchEmitter.cs @@ -12,12 +12,15 @@ public static void EmitDispatcher( string mismatchMessage, AnalyzedOverloadSet overloadSet, bool isStaticDispatcher, - Func getMethod, - Func> getParameters, - Func getParameterType, - Func hasDefaultValue, Func getOverloadMethodName) { + if (overloadSet.Overloads.Count == 1) + { + EmitSingleOverloadDispatcher(sb, dispatcherMethodName, mismatchMessage, overloadSet.Overloads[0], + isStaticDispatcher, getOverloadMethodName); + return; + } + sb.Append(" private ") .Append(isStaticDispatcher ? "static " : string.Empty) .Append("global::Okojo.JsValue ") @@ -46,9 +49,9 @@ public static void EmitDispatcher( EmitBucketCandidateChecks( sb, bucket.Candidates, - overloadSet.Overloads, " ", - false); + false, + getOverloadMethodName); sb.AppendLine(" break;"); sb.AppendLine(" }"); } @@ -65,9 +68,9 @@ public static void EmitDispatcher( EmitBucketCandidateChecks( sb, overloadSet.OpenEndedCandidates, - overloadSet.Overloads, " ", - true); + true, + getOverloadMethodName); } sb.AppendLine(" break;"); @@ -91,6 +94,59 @@ public static void EmitDispatcher( sb.AppendLine(" }"); } + private static void EmitSingleOverloadDispatcher( + StringBuilder sb, + string dispatcherMethodName, + string mismatchMessage, + AnalyzedOverload overload, + bool isStaticDispatcher, + Func getOverloadMethodName) + { + sb.Append(" private ") + .Append(isStaticDispatcher ? "static " : string.Empty) + .Append("global::Okojo.JsValue ") + .Append(dispatcherMethodName) + .AppendLine("(scoped in global::Okojo.Runtime.CallInfo info)"); + sb.AppendLine(" {"); + + if (overload.ParameterSpecs.Count == 0 && !overload.HasOpenEndedCount) + { + sb.AppendLine(" if (info.ArgumentCount != 0)"); + sb.Append(" throw new global::Okojo.Runtime.JsRuntimeException(global::Okojo.Runtime.JsErrorKind.TypeError, \"") + .Append(EscapeString(mismatchMessage)) + .AppendLine("\");"); + sb.Append(" return ").Append(getOverloadMethodName(overload.Index)).AppendLine("(info);"); + sb.AppendLine(" }"); + return; + } + + sb.AppendLine(" int __jsArgCount = info.ArgumentCount;"); + sb.AppendLine(" scoped global::System.ReadOnlySpan __jsArgs = info.Arguments;"); + if (overload.HasOpenEndedCount) + { + sb.Append(" if (__jsArgCount < ") + .Append(overload.RequiredCount.ToString(CultureInfo.InvariantCulture)) + .AppendLine(")"); + sb.Append(" throw new global::Okojo.Runtime.JsRuntimeException(global::Okojo.Runtime.JsErrorKind.TypeError, \"") + .Append(EscapeString(mismatchMessage)) + .AppendLine("\");"); + } + else + { + sb.Append(" if (__jsArgCount < ") + .Append(overload.RequiredCount.ToString(CultureInfo.InvariantCulture)) + .Append(" || __jsArgCount > ") + .Append(overload.MaxCount.ToString(CultureInfo.InvariantCulture)) + .AppendLine(")"); + sb.Append(" throw new global::Okojo.Runtime.JsRuntimeException(global::Okojo.Runtime.JsErrorKind.TypeError, \"") + .Append(EscapeString(mismatchMessage)) + .AppendLine("\");"); + } + + sb.Append(" return ").Append(getOverloadMethodName(overload.Index)).AppendLine("(info);"); + sb.AppendLine(" }"); + } + private static void EmitHoistedArgs(StringBuilder sb, int count, string indent) { for (var i = 0; i < count; i++) @@ -108,10 +164,16 @@ private static void EmitHoistedArgs(StringBuilder sb, int count, string indent) private static void EmitBucketCandidateChecks( StringBuilder sb, IReadOnlyList> candidates, - IReadOnlyList> allOverloads, string indent, - bool useCountCondition) + bool useCountCondition, + Func getOverloadMethodName) { + if (candidates.Count == 1) + { + EmitSingleCandidateCheck(sb, candidates[0], indent, useCountCondition, getOverloadMethodName); + return; + } + for (var i = 0; i < candidates.Count; i++) { var overload = candidates[i]; @@ -128,18 +190,10 @@ private static void EmitBucketCandidateChecks( sb.Append(indent).AppendLine(" int __jsScore = 0;"); sb.Append(indent).AppendLine(" bool __jsMatched = true;"); EmitOverloadMatcherBody(sb, overload, indent + " ", !useCountCondition); - var overloadIndex = -1; - for (var overloadCursor = 0; overloadCursor < allOverloads.Count; overloadCursor++) - if (ReferenceEquals(allOverloads[overloadCursor], overload)) - { - overloadIndex = overloadCursor; - break; - } - sb.Append(indent).AppendLine(" if (__jsMatched && __jsScore < __jsBestScore)"); sb.Append(indent).AppendLine(" {"); sb.Append(indent).Append(" __jsBestIndex = ") - .Append(overloadIndex.ToString(CultureInfo.InvariantCulture)).AppendLine(";"); + .Append(overload.Index.ToString(CultureInfo.InvariantCulture)).AppendLine(";"); sb.Append(indent).AppendLine(" __jsBestScore = __jsScore;"); sb.Append(indent).AppendLine(" }"); @@ -147,6 +201,37 @@ private static void EmitBucketCandidateChecks( } } + private static void EmitSingleCandidateCheck( + StringBuilder sb, + AnalyzedOverload overload, + string indent, + bool useCountCondition, + Func getOverloadMethodName) + { + if (useCountCondition) + { + sb.Append(indent) + .Append("if (__jsArgCount < ") + .Append(overload.RequiredCount.ToString(CultureInfo.InvariantCulture)) + .AppendLine(")"); + sb.Append(indent).AppendLine(" break;"); + } + + if (overload.ParameterSpecs.Count == 0 && !overload.HasOpenEndedCount) + { + sb.Append(indent).Append("return ").Append(getOverloadMethodName(overload.Index)).AppendLine("(info);"); + return; + } + + sb.Append(indent).AppendLine("{"); + sb.Append(indent).AppendLine(" int __jsScore = 0;"); + sb.Append(indent).AppendLine(" bool __jsMatched = true;"); + EmitOverloadMatcherBody(sb, overload, indent + " ", !useCountCondition); + sb.Append(indent).AppendLine(" if (__jsMatched)"); + sb.Append(indent).Append(" return ").Append(getOverloadMethodName(overload.Index)).AppendLine("(info);"); + sb.Append(indent).AppendLine("}"); + } + private static void EmitOverloadMatcherBody( StringBuilder sb, AnalyzedOverload overload, diff --git a/src/Okojo.SourceGenerator/OverloadGeneration/OverloadDispatchAnalysis.cs b/src/Okojo.SourceGenerator/OverloadGeneration/OverloadDispatchAnalysis.cs index da76606..85c9804 100644 --- a/src/Okojo.SourceGenerator/OverloadGeneration/OverloadDispatchAnalysis.cs +++ b/src/Okojo.SourceGenerator/OverloadGeneration/OverloadDispatchAnalysis.cs @@ -36,6 +36,7 @@ internal sealed class OverloadParameterSpec( } internal sealed class AnalyzedOverload( + int index, TMethod method, IMethodSymbol symbol, IReadOnlyList parameters, @@ -43,6 +44,7 @@ internal sealed class AnalyzedOverload( int requiredCount, int maxCount) { + public int Index { get; } = index; public TMethod Method { get; } = method; public IMethodSymbol Symbol { get; } = symbol; public IReadOnlyList Parameters { get; } = parameters; @@ -167,7 +169,7 @@ private static AnalyzedOverloadSet AnalyzeSet(method, symbol, parameters, specs, requiredCount, maxCount); + new AnalyzedOverload(i, method, symbol, parameters, specs, requiredCount, maxCount); overloads.Add(overload); if (overload.HasOpenEndedCount) openEnded.Add(overload); diff --git a/src/Okojo.SourceGenerator/README.md b/src/Okojo.SourceGenerator/README.md index cc35257..634bfc3 100644 --- a/src/Okojo.SourceGenerator/README.md +++ b/src/Okojo.SourceGenerator/README.md @@ -8,6 +8,13 @@ It reads annotations from `Okojo.Annotations` and emits generated code for: - generated global property lists - generated object export glue +Current export model: + +- `[GenerateJsGlobals]` collects members annotated with `[JsMember]`, `[JsGlobalFunction]`, or `[JsGlobalProperty]` +- `[GenerateJsObject]` collects only members annotated with `[JsMember]` +- shared members can opt out per surface with `[JsIgnoreFromGlobals]` or `[JsIgnoreFromObject]` +- unnamed exports default to a JavaScript-friendly lower-first-character name + ## Typical use Add both `Okojo.Annotations` and `Okojo.SourceGenerator` to a project that defines Okojo globals or objects. @@ -19,4 +26,4 @@ Add both `Okojo.Annotations` and `Okojo.SourceGenerator` to a project that defin ``` -Then annotate your types with attributes such as `GenerateJsGlobalsAttribute` or `GenerateJsObjectAttribute`. +Then annotate your types with attributes such as `GenerateJsGlobalsAttribute` or `GenerateJsObjectAttribute`, and mark the members you want exported with `[JsMember]`. diff --git a/src/Okojo/JsValue.cs b/src/Okojo/JsValue.cs index ae87651..663c461 100644 --- a/src/Okojo/JsValue.cs +++ b/src/Okojo/JsValue.cs @@ -178,6 +178,363 @@ public bool TryGetObject([NotNullWhen(true)] out JsObject? obj) return false; } + public bool TryRead(out T value) + { + if (typeof(T) == typeof(JsValue)) + { + value = Unsafe.As(ref Unsafe.AsRef(in this)); + return true; + } + + if (typeof(T) == typeof(string)) + { + if (IsNull) + { + string? nullString = null; + value = Unsafe.As(ref nullString)!; + return true; + } + + if (IsString) + { + var stringValue = AsString(); + value = Unsafe.As(ref stringValue); + return true; + } + + value = default!; + return false; + } + + if (typeof(T) == typeof(bool)) + { + if (IsBool) + { + var boolValue = IsTrue; + value = Unsafe.As(ref boolValue); + return true; + } + + value = default!; + return false; + } + + var hasNumber = TryGetNumberValue(this, out var number); + if (typeof(T) == typeof(int)) + { + if (!hasNumber) + { + value = default!; + return false; + } + + return TryReadInt32(number, out value); + } + if (typeof(T) == typeof(uint)) + { + if (!hasNumber) + { + value = default!; + return false; + } + + return TryReadUInt32(number, out value); + } + if (typeof(T) == typeof(long)) + { + if (!hasNumber) + { + value = default!; + return false; + } + + return TryReadInt64(number, out value); + } + if (typeof(T) == typeof(ulong)) + { + if (!hasNumber) + { + value = default!; + return false; + } + + return TryReadUInt64(number, out value); + } + if (typeof(T) == typeof(short)) + { + if (!hasNumber) + { + value = default!; + return false; + } + + return TryReadInt16(number, out value); + } + if (typeof(T) == typeof(ushort)) + { + if (!hasNumber) + { + value = default!; + return false; + } + + return TryReadUInt16(number, out value); + } + if (typeof(T) == typeof(byte)) + { + if (!hasNumber) + { + value = default!; + return false; + } + + return TryReadByte(number, out value); + } + if (typeof(T) == typeof(sbyte)) + { + if (!hasNumber) + { + value = default!; + return false; + } + + return TryReadSByte(number, out value); + } + if (typeof(T) == typeof(float)) + { + if (!hasNumber) + { + value = default!; + return false; + } + + return TryReadSingle(number, out value); + } + if (typeof(T) == typeof(double)) + { + if (!hasNumber) + { + value = default!; + return false; + } + + return TryReadDouble(number, out value); + } + if (typeof(T) == typeof(decimal)) + { + if (!hasNumber) + { + value = default!; + return false; + } + + return TryReadDecimal(number, out value); + } + + if (typeof(T) == typeof(object)) + { + object? boxed = IsUndefined || IsNull + ? null + : IsString + ? AsString() + : IsBool + ? IsTrue + : IsInt32 + ? Int32Value + : IsNumber + ? NumberValue + : TryGetObject(out var boxedObject) + ? boxedObject is Objects.JsHostObject host ? host.Data : boxedObject + : this; + value = Unsafe.As(ref boxed)!; + return true; + } + + if (typeof(T) == typeof(Objects.JsObject)) + { + if (TryGetObject(out var obj)) + { + value = Unsafe.As(ref obj); + return true; + } + + value = default!; + return false; + } + + if (TryGetObject(out var hostObj)) + { + if (hostObj is Objects.JsHostObject host && host.Data is T hostData) + { + value = hostData; + return true; + } + + if (hostObj is T direct) + { + value = direct; + return true; + } + } + + if (IsNullOrUndefined && Nullable.GetUnderlyingType(typeof(T)) is not null) + { + value = default!; + return true; + } + + value = default!; + return false; + } + + private static bool TryReadInt32(double number, out T value) + { + try + { + var typedValue = checked((int)number); + value = Unsafe.As(ref typedValue); + return true; + } + catch (OverflowException) + { + value = default!; + return false; + } + } + + private static bool TryReadUInt32(double number, out T value) + { + try + { + var typedValue = checked((uint)number); + value = Unsafe.As(ref typedValue); + return true; + } + catch (OverflowException) + { + value = default!; + return false; + } + } + + private static bool TryReadInt64(double number, out T value) + { + try + { + var typedValue = checked((long)number); + value = Unsafe.As(ref typedValue); + return true; + } + catch (OverflowException) + { + value = default!; + return false; + } + } + + private static bool TryReadUInt64(double number, out T value) + { + try + { + var typedValue = checked((ulong)number); + value = Unsafe.As(ref typedValue); + return true; + } + catch (OverflowException) + { + value = default!; + return false; + } + } + + private static bool TryReadInt16(double number, out T value) + { + try + { + var typedValue = checked((short)number); + value = Unsafe.As(ref typedValue); + return true; + } + catch (OverflowException) + { + value = default!; + return false; + } + } + + private static bool TryReadUInt16(double number, out T value) + { + try + { + var typedValue = checked((ushort)number); + value = Unsafe.As(ref typedValue); + return true; + } + catch (OverflowException) + { + value = default!; + return false; + } + } + + private static bool TryReadByte(double number, out T value) + { + try + { + var typedValue = checked((byte)number); + value = Unsafe.As(ref typedValue); + return true; + } + catch (OverflowException) + { + value = default!; + return false; + } + } + + private static bool TryReadSByte(double number, out T value) + { + try + { + var typedValue = checked((sbyte)number); + value = Unsafe.As(ref typedValue); + return true; + } + catch (OverflowException) + { + value = default!; + return false; + } + } + + private static bool TryReadSingle(double number, out T value) + { + var typedValue = (float)number; + value = Unsafe.As(ref typedValue); + return true; + } + + private static bool TryReadDouble(double number, out T value) + { + value = Unsafe.As(ref number); + return true; + } + + private static bool TryReadDecimal(double number, out T value) + { + try + { + var typedValue = Convert.ToDecimal(number, CultureInfo.InvariantCulture); + value = Unsafe.As(ref typedValue); + return true; + } + catch (OverflowException) + { + value = default!; + return false; + } + } + public JsValue(double d) { if (double.IsNaN(d)) diff --git a/src/Okojo/Runtime/CallInfo.cs b/src/Okojo/Runtime/CallInfo.cs index f0ac98f..d71bf72 100644 --- a/src/Okojo/Runtime/CallInfo.cs +++ b/src/Okojo/Runtime/CallInfo.cs @@ -133,82 +133,14 @@ private T ConvertArgument(in JsValue value, bool isReceiver) { try { - if (typeof(T) == typeof(JsValue)) return Unsafe.As(ref Unsafe.AsRef(in value)); + if (value.TryRead(out var directValue)) + return directValue; if (typeof(T) == typeof(bool)) { var boolValue = value.ToBoolean(); return Unsafe.As(ref boolValue); } - if (value.IsNumber) - { - var numberValue = value.NumberValue; - if (typeof(T).IsPrimitive) - { - if (typeof(T) == typeof(double)) return Unsafe.As(ref numberValue); - - if (typeof(T) == typeof(float)) - { - var floatValue = (float)numberValue; - return Unsafe.As(ref floatValue); - } - - if (typeof(T) == typeof(int)) - { - var intValue = (int)numberValue; - return Unsafe.As(ref intValue); - } - - if (typeof(T) == typeof(long)) - { - var longValue = (long)numberValue; - return Unsafe.As(ref longValue); - } - - if (typeof(T) == typeof(short)) - { - var shortValue = (short)numberValue; - return Unsafe.As(ref shortValue); - } - - if (typeof(T) == typeof(byte)) - { - var byteValue = (byte)numberValue; - return Unsafe.As(ref byteValue); - } - - if (typeof(T) == typeof(uint)) - { - var uintValue = (uint)numberValue; - return Unsafe.As(ref uintValue); - } - - if (typeof(T) == typeof(ulong)) - { - var ulongValue = (ulong)numberValue; - return Unsafe.As(ref ulongValue); - } - - if (typeof(T) == typeof(ushort)) - { - var ushortValue = (ushort)numberValue; - return Unsafe.As(ref ushortValue); - } - - if (typeof(T) == typeof(sbyte)) - { - var sbyteValue = (sbyte)numberValue; - return Unsafe.As(ref sbyteValue); - } - } - } - - if (value.IsString && typeof(T) == typeof(string)) - { - var stringValue = value.AsString(); - return Unsafe.As(ref stringValue); - } - return HostValueConverter.ConvertFromJsValue(Realm, value); } catch (Exception ex) when (ex is InvalidOperationException or InvalidCastException or OverflowException diff --git a/src/Okojo/Runtime/Interop/HostValueConverter.cs b/src/Okojo/Runtime/Interop/HostValueConverter.cs index 7d0390b..d6b4a05 100644 --- a/src/Okojo/Runtime/Interop/HostValueConverter.cs +++ b/src/Okojo/Runtime/Interop/HostValueConverter.cs @@ -203,27 +203,27 @@ internal static bool TryGetConversionScore(JsRealm realm, in JsValue value, o } if (typeof(T) == typeof(int)) - return TryGetNumericScore(value, typeof(int), out score); + return TryGetInt32NumericScore(value, out score); if (typeof(T) == typeof(uint)) - return TryGetNumericScore(value, typeof(uint), out score); + return TryGetUInt32NumericScore(value, out score); if (typeof(T) == typeof(long)) - return TryGetNumericScore(value, typeof(long), out score); + return TryGetInt64NumericScore(value, out score); if (typeof(T) == typeof(ulong)) - return TryGetNumericScore(value, typeof(ulong), out score); + return TryGetUInt64NumericScore(value, out score); if (typeof(T) == typeof(short)) - return TryGetNumericScore(value, typeof(short), out score); + return TryGetInt16NumericScore(value, out score); if (typeof(T) == typeof(ushort)) - return TryGetNumericScore(value, typeof(ushort), out score); + return TryGetUInt16NumericScore(value, out score); if (typeof(T) == typeof(byte)) - return TryGetNumericScore(value, typeof(byte), out score); + return TryGetByteNumericScore(value, out score); if (typeof(T) == typeof(sbyte)) - return TryGetNumericScore(value, typeof(sbyte), out score); + return TryGetSByteNumericScore(value, out score); if (typeof(T) == typeof(float)) - return TryGetNumericScore(value, typeof(float), out score); + return TryGetSingleNumericScore(value, out score); if (typeof(T) == typeof(double)) - return TryGetNumericScore(value, typeof(double), out score); + return TryGetDoubleNumericScore(value, out score); if (typeof(T) == typeof(decimal)) - return TryGetNumericScore(value, typeof(decimal), out score); + return TryGetDecimalNumericScore(value, out score); if (typeof(T) == typeof(object)) { @@ -712,198 +712,326 @@ private static double ConvertToDouble(JsValue value) } private static bool TryGetNumericScore(in JsValue value, Type targetType, out int score) + { + if (targetType == typeof(int)) + return TryGetInt32NumericScore(value, out score); + if (targetType == typeof(uint)) + return TryGetUInt32NumericScore(value, out score); + if (targetType == typeof(long)) + return TryGetInt64NumericScore(value, out score); + if (targetType == typeof(ulong)) + return TryGetUInt64NumericScore(value, out score); + if (targetType == typeof(short)) + return TryGetInt16NumericScore(value, out score); + if (targetType == typeof(ushort)) + return TryGetUInt16NumericScore(value, out score); + if (targetType == typeof(byte)) + return TryGetByteNumericScore(value, out score); + if (targetType == typeof(sbyte)) + return TryGetSByteNumericScore(value, out score); + if (targetType == typeof(float)) + return TryGetSingleNumericScore(value, out score); + if (targetType == typeof(double)) + return TryGetDoubleNumericScore(value, out score); + if (targetType == typeof(decimal)) + return TryGetDecimalNumericScore(value, out score); + + score = 0; + return false; + } + + private static bool TryGetConversionScoreSlow(JsRealm realm, in JsValue value, Type targetType, out int score) + { + return TryConvertFromJsValue(realm, value, targetType, out _, out score); + } + + private static bool TryConvertNumeric(JsValue value, Type targetType, out object? result, out int score) { score = 0; if (!JsValue.TryGetNumberValue(value, out var number)) + { + result = null; return false; + } try { if (targetType == typeof(int)) { - _ = checked((int)number); + result = checked((int)number); score = value.IsInt32 ? 0 : 1; return true; } if (targetType == typeof(uint)) { - _ = checked((uint)number); + result = checked((uint)number); score = value.IsInt32 && value.Int32Value >= 0 ? 0 : 1; return true; } if (targetType == typeof(long)) { - _ = checked((long)number); + result = checked((long)number); score = value.IsInt32 ? 0 : 1; return true; } if (targetType == typeof(ulong)) { - _ = checked((ulong)number); + result = checked((ulong)number); score = value.IsInt32 && value.Int32Value >= 0 ? 0 : 1; return true; } if (targetType == typeof(short)) { - _ = checked((short)number); + result = checked((short)number); score = value.IsInt32 ? 1 : 2; return true; } if (targetType == typeof(ushort)) { - _ = checked((ushort)number); + result = checked((ushort)number); score = value.IsInt32 && value.Int32Value >= 0 ? 1 : 2; return true; } if (targetType == typeof(byte)) { - _ = checked((byte)number); + result = checked((byte)number); score = value.IsInt32 && value.Int32Value >= 0 ? 1 : 2; return true; } if (targetType == typeof(sbyte)) { - _ = checked((sbyte)number); + result = checked((sbyte)number); score = value.IsInt32 ? 1 : 2; return true; } if (targetType == typeof(float)) { - _ = (float)number; + result = (float)number; score = 1; return true; } if (targetType == typeof(double)) { + result = number; score = 0; return true; } if (targetType == typeof(decimal)) { - _ = Convert.ToDecimal(number, CultureInfo.InvariantCulture); + result = Convert.ToDecimal(number, CultureInfo.InvariantCulture); score = 1; return true; } } catch (OverflowException) { + result = null; return false; } + result = null; return false; } - private static bool TryGetConversionScoreSlow(JsRealm realm, in JsValue value, Type targetType, out int score) + private static bool TryGetInt32NumericScore(in JsValue value, out int score) { - return TryConvertFromJsValue(realm, value, targetType, out _, out score); + score = 0; + if (!TryGetNumber(value, out var number)) + return false; + + try + { + _ = checked((int)number); + score = value.IsInt32 ? 0 : 1; + return true; + } + catch (OverflowException) + { + return false; + } } - private static bool TryConvertNumeric(JsValue value, Type targetType, out object? result, out int score) + private static bool TryGetUInt32NumericScore(in JsValue value, out int score) { score = 0; - if (!JsValue.TryGetNumberValue(value, out var number)) + if (!TryGetNumber(value, out var number)) + return false; + + try + { + _ = checked((uint)number); + score = IsNonNegativeInt32(value) ? 0 : 1; + return true; + } + catch (OverflowException) { - result = null; return false; } + } + + private static bool TryGetInt64NumericScore(in JsValue value, out int score) + { + score = 0; + if (!TryGetNumber(value, out var number)) + return false; try { - if (targetType == typeof(int)) - { - result = checked((int)number); - score = value.IsInt32 ? 0 : 1; - return true; - } + _ = checked((long)number); + score = value.IsInt32 ? 0 : 1; + return true; + } + catch (OverflowException) + { + return false; + } + } - if (targetType == typeof(uint)) - { - result = checked((uint)number); - score = value.IsInt32 && value.Int32Value >= 0 ? 0 : 1; - return true; - } + private static bool TryGetUInt64NumericScore(in JsValue value, out int score) + { + score = 0; + if (!TryGetNumber(value, out var number)) + return false; - if (targetType == typeof(long)) - { - result = checked((long)number); - score = value.IsInt32 ? 0 : 1; - return true; - } + try + { + _ = checked((ulong)number); + score = IsNonNegativeInt32(value) ? 0 : 1; + return true; + } + catch (OverflowException) + { + return false; + } + } - if (targetType == typeof(ulong)) - { - result = checked((ulong)number); - score = value.IsInt32 && value.Int32Value >= 0 ? 0 : 1; - return true; - } + private static bool TryGetInt16NumericScore(in JsValue value, out int score) + { + score = 0; + if (!TryGetNumber(value, out var number)) + return false; - if (targetType == typeof(short)) - { - result = checked((short)number); - score = value.IsInt32 ? 1 : 2; - return true; - } + try + { + _ = checked((short)number); + score = value.IsInt32 ? 1 : 2; + return true; + } + catch (OverflowException) + { + return false; + } + } - if (targetType == typeof(ushort)) - { - result = checked((ushort)number); - score = value.IsInt32 && value.Int32Value >= 0 ? 1 : 2; - return true; - } + private static bool TryGetUInt16NumericScore(in JsValue value, out int score) + { + score = 0; + if (!TryGetNumber(value, out var number)) + return false; - if (targetType == typeof(byte)) - { - result = checked((byte)number); - score = value.IsInt32 && value.Int32Value >= 0 ? 1 : 2; - return true; - } + try + { + _ = checked((ushort)number); + score = IsNonNegativeInt32(value) ? 1 : 2; + return true; + } + catch (OverflowException) + { + return false; + } + } - if (targetType == typeof(sbyte)) - { - result = checked((sbyte)number); - score = value.IsInt32 ? 1 : 2; - return true; - } + private static bool TryGetByteNumericScore(in JsValue value, out int score) + { + score = 0; + if (!TryGetNumber(value, out var number)) + return false; - if (targetType == typeof(float)) - { - result = (float)number; - score = 1; - return true; - } + try + { + _ = checked((byte)number); + score = IsNonNegativeInt32(value) ? 1 : 2; + return true; + } + catch (OverflowException) + { + return false; + } + } - if (targetType == typeof(double)) - { - result = number; - score = 0; - return true; - } + private static bool TryGetSByteNumericScore(in JsValue value, out int score) + { + score = 0; + if (!TryGetNumber(value, out var number)) + return false; - if (targetType == typeof(decimal)) - { - result = Convert.ToDecimal(number, CultureInfo.InvariantCulture); - score = 1; - return true; - } + try + { + _ = checked((sbyte)number); + score = value.IsInt32 ? 1 : 2; + return true; } catch (OverflowException) { - result = null; return false; } + } - result = null; - return false; + private static bool TryGetSingleNumericScore(in JsValue value, out int score) + { + score = 0; + if (!TryGetNumber(value, out var number)) + return false; + + _ = (float)number; + score = 1; + return true; + } + + private static bool TryGetDoubleNumericScore(in JsValue value, out int score) + { + score = 0; + return TryGetNumber(value, out _); + } + + private static bool TryGetDecimalNumericScore(in JsValue value, out int score) + { + score = 0; + if (!TryGetNumber(value, out var number)) + return false; + + try + { + _ = Convert.ToDecimal(number, CultureInfo.InvariantCulture); + score = 1; + return true; + } + catch (OverflowException) + { + return false; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool TryGetNumber(in JsValue value, out double number) + { + return JsValue.TryGetNumberValue(value, out number); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsNonNegativeInt32(in JsValue value) + { + return value.IsInt32 && value.Int32Value >= 0; } private static bool AllowsNull(Type type) diff --git a/tests/Okojo.Tests/GeneratedDocDeclarationTests.cs b/tests/Okojo.Tests/GeneratedDocDeclarationTests.cs index 2fbaeef..aa0f20e 100644 --- a/tests/Okojo.Tests/GeneratedDocDeclarationTests.cs +++ b/tests/Okojo.Tests/GeneratedDocDeclarationTests.cs @@ -61,12 +61,15 @@ public async Task DocGenerator_Emits_Rest_Params_For_ReadOnlySpan() Does.Contain("declare function background(r: number, g: number, b: number, a: number): void;")); Assert.That(globalsText, Does.Contain("declare function pick(value: string): string;")); Assert.That(globalsText, Does.Contain("declare function pick(value: number): string;")); - Assert.That(objectText, Does.Contain("static SumNumbers(...values: number[]): number;")); - Assert.That(objectText, Does.Contain("static DescribeJsValues(...values: any[]): string;")); - Assert.That(objectText, Does.Contain("static DescribeAny(...values: any[]): string;")); - Assert.That(objectText, Does.Contain("static Pick(value: string): string;")); - Assert.That(objectText, Does.Contain("static Pick(value: number): string;")); - Assert.That(objectText, Does.Contain("static Pick(value: any): string;")); + Assert.That(objectText, Does.Contain("x: number;")); + Assert.That(objectText, Does.Contain("static sin(a: number): number;")); + Assert.That(objectText, Does.Contain("static sumNumbers(...values: number[]): number;")); + Assert.That(objectText, Does.Contain("static describeJsValues(...values: any[]): string;")); + Assert.That(objectText, Does.Contain("static describeAny(...values: any[]): string;")); + Assert.That(objectText, Does.Contain("static pick(value: string): string;")); + Assert.That(objectText, Does.Contain("static pick(value: number): string;")); + Assert.That(objectText, Does.Contain("static pick(value: any): string;")); + Assert.That(objectText, Does.Not.Contain("echo(value: string): string;")); } finally { diff --git a/tests/Okojo.Tests/GeneratedGlobalInstallerTests.cs b/tests/Okojo.Tests/GeneratedGlobalInstallerTests.cs index d82f4f2..4f21d5f 100644 --- a/tests/Okojo.Tests/GeneratedGlobalInstallerTests.cs +++ b/tests/Okojo.Tests/GeneratedGlobalInstallerTests.cs @@ -10,42 +10,43 @@ internal sealed partial class GeneratedGlobalInstallerSample { public int WidthValue { get; set; } = 320; - [JsGlobalProperty("width")] public int Width => WidthValue; + [JsMember] public int Width => WidthValue; - [JsGlobalProperty("strokeWidth", Writable = true)] + [JsMember] + [JsGlobalProperty(Writable = true)] public int StrokeWidth { get; set; } = 2; public string LastBackground { get; private set; } = string.Empty; /// Sets the background color from a named color string. - [JsGlobalFunction("background")] + [JsMember("background")] private void Background(string color) { LastBackground = $"named:{color}"; } /// Sets the background color from a grayscale value. - [JsGlobalFunction("background")] + [JsMember("background")] private void Background(byte gray) { LastBackground = $"gray:{gray}"; } /// Sets the background color from RGB values. - [JsGlobalFunction("background")] + [JsMember("background")] private void Background(byte r, byte g, byte b) { LastBackground = $"{r},{g},{b},255"; } /// Sets the background color from RGBA values. - [JsGlobalFunction("background")] + [JsMember("background")] private void Background(byte r, byte g, byte b, byte a) { LastBackground = $"{r},{g},{b},{a}"; } - [JsGlobalFunction("sumNumbers")] + [JsMember] private int SumNumbers(ReadOnlySpan values) { var sum = 0; @@ -54,7 +55,7 @@ private int SumNumbers(ReadOnlySpan values) return sum; } - [JsGlobalFunction("describeAny")] + [JsMember] private string DescribeAny(ReadOnlySpan values) { if (values.Length == 0) @@ -66,13 +67,13 @@ private string DescribeAny(ReadOnlySpan values) return string.Join("|", parts); } - [JsGlobalFunction("pick")] + [JsMember] private string Pick(string value) { return $"string:{value}"; } - [JsGlobalFunction("pick")] + [JsMember] private string Pick(int value) { return $"number:{value}"; diff --git a/tests/Okojo.Tests/HostInteropTests.cs b/tests/Okojo.Tests/HostInteropTests.cs index 52478e2..20cdbce 100644 --- a/tests/Okojo.Tests/HostInteropTests.cs +++ b/tests/Okojo.Tests/HostInteropTests.cs @@ -199,19 +199,23 @@ private static HostBinding CreateHostBinding() [DocDeclaration("Foo\\Bar", "Docs.Shapes")] public partial class GeneratedHostBindingSample { + [JsMember] public float X { get; set; } + [JsMember] public static float Sin(float a) { return MathF.Sin(a); } [DocIgnore] + [JsMember] public string Echo(string value) { return $"echo:{value}"; } + [JsMember] public static int SumNumbers(ReadOnlySpan values) { var sum = 0; @@ -220,6 +224,7 @@ public static int SumNumbers(ReadOnlySpan values) return sum; } + [JsMember] public static string DescribeJsValues(ReadOnlySpan values) { if (values.Length == 0) @@ -231,6 +236,7 @@ public static string DescribeJsValues(ReadOnlySpan values) return string.Join("|", parts); } + [JsMember] public static string DescribeAny(ReadOnlySpan values) { if (values.Length == 0) @@ -242,16 +248,19 @@ public static string DescribeAny(ReadOnlySpan values) return string.Join("|", parts); } + [JsMember] public static string Pick(string value) { return $"string:{value}"; } + [JsMember] public static string Pick(int value) { return $"number:{value}"; } + [JsMember] public static string Pick(object value) { return $"object:{value}"; @@ -372,18 +381,74 @@ public ValueTask DisposeAsync() [DocDeclaration("Foo\\Bar", "Docs.Shapes")] public partial class GeneratedAsyncHostBindingSample { + [JsMember] public static async Task EchoAsync(string value) { await Task.Yield(); return $"generated:{value}"; } + [JsMember] public static async Task AwaitEcho(Task value) { return "generated-await:" + await value; } } +[GenerateJsObject] +[GenerateJsGlobals] +internal sealed partial class SharedJsMemberSurfaceSample +{ + [JsMember] + public int SharedValue { get; set; } + + [JsMember] + public static string SharedAction() + { + return "shared"; + } + + [JsMember] + [JsIgnoreFromGlobals] + public static string ObjectOnly() + { + return "object"; + } + + [JsMember] + [JsIgnoreFromObject] + public static string GlobalOnly() + { + return "global"; + } +} + +[GenerateJsObject(MemberNaming = JsMemberNaming.PascalCase)] +internal sealed partial class PascalCaseJsMemberObjectSample +{ + [JsMember] + private int sampleValue { get; set; } + + [JsMember] + private string doThing() + { + return "pascal"; + } +} + +[GenerateJsGlobals(MemberNaming = JsMemberNaming.AsDeclared)] +internal sealed partial class AsDeclaredJsGlobalsSample +{ + [JsMember] + public int SampleValue => 7; + + [JsMember] + private string DoThing() + { + return "declared"; + } +} + public class HostInteropTests { private static JsRealm CreateClrRealm() @@ -1317,11 +1382,11 @@ public void GeneratedHostBindingSupportsGeneratedMembers() var script = JsCompiler.Compile(realm, JavaScriptParser.ParseScript(""" const type = GeneratedHostBindingSample; const sample = new type(); - sample.X = 2.5; + sample.x = 2.5; [ - sample.X, - sample.Echo("ok"), - type.Sin(0) + sample.x, + sample.echo("ok"), + type.sin(0) ].join("|"); """)); @@ -1339,12 +1404,12 @@ public void GeneratedHostBindingSupportsReadOnlySpanArguments() var script = JsCompiler.Compile(realm, JavaScriptParser.ParseScript(""" [ - GeneratedHostBindingSample.SumNumbers(1, 2, 3, 4), - GeneratedHostBindingSample.DescribeJsValues(1, "x", true), - GeneratedHostBindingSample.DescribeAny(1, "x", true), - GeneratedHostBindingSample.Pick("x"), - GeneratedHostBindingSample.Pick(7), - GeneratedHostBindingSample.Pick(true) + GeneratedHostBindingSample.sumNumbers(1, 2, 3, 4), + GeneratedHostBindingSample.describeJsValues(1, "x", true), + GeneratedHostBindingSample.describeAny(1, "x", true), + GeneratedHostBindingSample.pick("x"), + GeneratedHostBindingSample.pick(7), + GeneratedHostBindingSample.pick(true) ].join("|"); """)); @@ -1382,12 +1447,12 @@ public void ManualAndGeneratedBindingsWorkWithoutClrAccess() const a = new ManualHostBindingSample(); const b = new GeneratedHostBindingSample(); a.X = 3.5; - b.X = 4.5; + b.x = 4.5; [ a.X, ManualHostBindingSample.Sin(0), - b.X, - GeneratedHostBindingSample.Sin(0) + b.x, + GeneratedHostBindingSample.sin(0) ].join("|"); """)); @@ -1449,8 +1514,8 @@ public async Task GeneratedHostBindingConvertsTaskAndPromiseWithoutClrAccess() var value = await realm.EvalAsync(""" (async () => { return [ - await GeneratedAsyncHostBindingSample.EchoAsync("ok"), - await GeneratedAsyncHostBindingSample.AwaitEcho(Promise.resolve("x")) + await GeneratedAsyncHostBindingSample.echoAsync("ok"), + await GeneratedAsyncHostBindingSample.awaitEcho(Promise.resolve("x")) ].join("|"); })() """); @@ -1467,7 +1532,7 @@ public async Task CallAsyncAwaitsReturnedPromise() JsValue.FromObject(GeneratedAsyncHostBindingSample.ToJsType(realm)); realm.Eval(""" async function runCallAsync() { - return await GeneratedAsyncHostBindingSample.EchoAsync("call"); + return await GeneratedAsyncHostBindingSample.echoAsync("call"); } """); @@ -1478,6 +1543,66 @@ async function runCallAsync() { Assert.That(value.AsString(), Is.EqualTo("generated:call")); } + [Test] + public void JsMember_Can_Be_Shared_And_Opted_Out_Per_Surface() + { + var sample = new SharedJsMemberSurfaceSample(); + using var runtime = JsRuntime.CreateBuilder() + .UseGlobals(sample.InstallGeneratedGlobals) + .Build(); + var realm = runtime.MainRealm; + realm.Global["SharedJsMemberSurfaceSample"] = JsValue.FromObject(SharedJsMemberSurfaceSample.ToJsType(realm)); + + var result = realm.Eval(""" + const type = SharedJsMemberSurfaceSample; + const instance = new type(); + instance.sharedValue = 5; + [ + sharedAction(), + typeof objectOnly, + globalOnly(), + type.sharedAction(), + type.objectOnly(), + typeof type.globalOnly, + instance.sharedValue + ].join("|"); + """); + + Assert.That(result.IsString, Is.True); + Assert.That(result.AsString(), Is.EqualTo("shared|undefined|global|shared|object|undefined|5")); + } + + [Test] + public void GenerateJsObject_Can_Use_PascalCase_MemberNaming() + { + var realm = JsRuntime.Create().DefaultRealm; + var sample = new PascalCaseJsMemberObjectSample(); + realm.Global["sample"] = JsValue.FromObject(PascalCaseJsMemberObjectSample.ToJsObject(realm, sample)); + + var result = realm.Eval(""" + sample.SampleValue = 9; + [sample.SampleValue, sample.DoThing()].join("|"); + """); + + Assert.That(result.IsString, Is.True); + Assert.That(result.AsString(), Is.EqualTo("9|pascal")); + } + + [Test] + public void GenerateJsGlobals_Can_Use_AsDeclared_MemberNaming() + { + var sample = new AsDeclaredJsGlobalsSample(); + using var runtime = JsRuntime.CreateBuilder() + .UseGlobals(sample.InstallGeneratedGlobals) + .Build(); + var realm = runtime.MainRealm; + + var result = realm.Eval("[SampleValue, DoThing()].join('|')"); + + Assert.That(result.IsString, Is.True); + Assert.That(result.AsString(), Is.EqualTo("7|declared")); + } + [Test] public void HostValueConverter_Generic_Conversion_Uses_Direct_Typed_Paths() { @@ -1501,6 +1626,30 @@ public void HostValueConverter_Generic_Conversion_Uses_Direct_Typed_Paths() }); } + [Test] + public void JsValue_TryRead_Uses_Direct_Typed_Paths() + { + using var runtime = JsRuntime.Create(options => options.AllowClrAccess()); + var realm = runtime.DefaultRealm; + var host = new ManualHostBindingSample { X = 7 }; + var hostObject = realm.WrapHostObject(host); + var hostValue = JsValue.FromObject(hostObject); + + Assert.Multiple(() => + { + Assert.That(JsValue.FromInt32(12).TryRead(out var intValue) && intValue == 12, Is.True); + Assert.That(new JsValue(12.5d).TryRead(out var doubleValue) && doubleValue == 12.5d, Is.True); + Assert.That(JsValue.FromString("ok").TryRead(out var stringValue) && stringValue == "ok", Is.True); + Assert.That(JsValue.True.TryRead(out var boolValue) && boolValue, Is.True); + Assert.That(hostValue.TryRead(out var objectValue) && ReferenceEquals(objectValue, hostObject), Is.True); + Assert.That(hostValue.TryRead(out var hostData) && ReferenceEquals(hostData, host), + Is.True); + Assert.That(JsValue.Null.TryRead(out var nullableString) && nullableString is null, Is.True); + Assert.That(JsValue.Null.TryRead(out var nullableInt) && nullableInt is null, Is.True); + Assert.That(JsValue.FromInt32(12).TryRead(out _), Is.False); + }); + } + private sealed class HostCounter { private readonly string[] items = ["a", "b", "c"];