diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 316a54bf..667c4eec 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -37,6 +37,9 @@ module's `.fsi` and the `.fs` on its `.fs`, so that Visual Studio shows them as
│ │ ├── FlatListTuple.fs / FlatListTuple.fsi – Tuple namespace overrides
│ │ ├── FlatListBuilder.fs / FlatListBuilder.fsi
│ │ └── FlatListCE.fs / FlatListCE.fsi
+│ ├── EquatableArray/ – ImmutableArray with value semantics, JSON converter, patterns
+│ │ ├── EquatableArray.fs / EquatableArray.fsi
+│ │ └── EquatableArrayPatterns.fs / EquatableArrayPatterns.fsi
│ ├── IndexedList/ – ImmutableList API surface and builders
│ │ ├── IndexedListTypes.fs
│ │ ├── IndexedList.fs / IndexedList.fsi
@@ -116,6 +119,7 @@ module's `.fsi` and the `.fs` on its `.fs`, so that Visual Studio shows them as
│ │ ├── FrozenSetTuple.fs / FrozenSetTuple.fsi
│ │ └── FrozenSetCE.fs / FrozenSetCE.fsi
│ └── ComputationExpressions.fs / .fsi – the computation expression instances
+├── tests/FSharp.Collections.Immutable.CSharp.Tests/ – MSTest project in C#: collection expressions, ranges, operators
└── tests/FSharp.Collections.Immutable.Tests/ – MSTest test project
├── TestCategories.fs – centralized custom test category attributes
├── TestCategoriesTests.fs – enforces the test category rules
@@ -149,8 +153,10 @@ The modules follow ADR 0001 – Per-collection modules (`docsSrc/Explanations/AD
* [`System.Collections.Immutable`](https://www.nuget.org/packages/System.Collections.Immutable/) – immutable collections foundation
* [`FSharp.Core`](https://www.nuget.org/packages/FSharp.Core/) – F# language core library
+* [`System.Text.Json`](https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/overview) – the JSON converter of `EquatableArray` (shared framework, no package)
* [`MSTest`](https://github.com/microsoft/testfx) – test framework
* [`Unquote`](https://github.com/SwensenSoftware/unquote) – expressive assertions for complex checks
+* [`FSharp.SystemTextJson`](https://github.com/Tarmil/FSharp.SystemTextJson) – JSON tests with F# records and unions
* [`FAKE`](https://fake.build/) – build and release scripting
* [`Argu`](https://github.com/fsprojects/Argu) – command-line parsing for build tooling
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8373ad55..a766df38 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -30,6 +30,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
semantics and exceptions. Its `tryFind`, `tryFindIndex` and `tryPick` return `voption`, `choose` and `tryPick` take
functions that return `voption`, and `groupBy`, `partition`, `partitionWith` and `zip` return struct tuples; the
`Parallel` modules of the `Option` and `Tuple` namespaces repeat them with options and reference tuples.
+* `EquatableArray<'T>`: a struct over one `ImmutableArray<'T>` with value semantics, for fields of records and unions,
+ dictionary and set keys and persisted documents (ADR 0002)
+ * Equality and the hash code use all elements; every caller gets them, C# `==` and `!=` included
+ * Comparison puts shorter arrays first, then orders by the first different element, in the order of F# comparison
+ * The default value is the empty array
+ * Conversion to and from `ImmutableArray<'T>` does not copy; C# collection expressions and ranges work
+ * A System.Text.Json converter that needs no registration
+ * The `EquatableArray` module: `empty`, `ofArray`, `ofImmutableArray`, `ofList`, `ofSeq`, `singleton`,
+ `unsafeOfArray`, `isEmpty`, `length`, `map`, `toImmutableArray`
+ * The struct partial active patterns `Arr0`, `Arr1`, `Arr2`, `Arr3` and `ArrN`
### Changed
diff --git a/Directory.Packages.props b/Directory.Packages.props
index def08789..8e238ee7 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -24,6 +24,7 @@
+
diff --git a/FSharp.Collections.Immutable.slnf b/FSharp.Collections.Immutable.slnf
index 097e0fa8..85556a30 100644
--- a/FSharp.Collections.Immutable.slnf
+++ b/FSharp.Collections.Immutable.slnf
@@ -3,6 +3,7 @@
"path": "FSharp.Collections.Immutable.slnx",
"projects": [
"src\\FSharp.Collections.Immutable\\FSharp.Collections.Immutable.fsproj",
+ "tests\\FSharp.Collections.Immutable.CSharp.Tests\\FSharp.Collections.Immutable.CSharp.Tests.csproj",
"tests\\FSharp.Collections.Immutable.Tests\\FSharp.Collections.Immutable.Tests.fsproj"
]
}
diff --git a/FSharp.Collections.Immutable.slnx b/FSharp.Collections.Immutable.slnx
index b2ce8282..803499e4 100644
--- a/FSharp.Collections.Immutable.slnx
+++ b/FSharp.Collections.Immutable.slnx
@@ -13,6 +13,7 @@
+
diff --git a/README.md b/README.md
index 950057e1..d7a66f81 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,7 @@ Please also join the [F# Open Source Group](http://fsharp.github.com)
| Module | .NET type | Computation expression |
|---|---|---|
| `FlatList` | `ImmutableArray<'T>` | `flatList { }` |
+| `EquatableArray` | `EquatableArray<'T>`, an `ImmutableArray<'T>` with value equality, comparison and an empty default value | – |
| `ImmutableList` | `ImmutableList<'T>` | – |
| `Stack` | `ImmutableStack<'T>` | – |
| `Queue` | `ImmutableQueue<'T>` | – |
diff --git a/docsSrc/Explanations/Background.md b/docsSrc/Explanations/Background.md
index 822820fa..d7f6b725 100644
--- a/docsSrc/Explanations/Background.md
+++ b/docsSrc/Explanations/Background.md
@@ -27,6 +27,7 @@ While F# has its own built-in immutable collections, System.Collections.Immutabl
| Module | .NET type | Computation expression |
|---|---|---|
| `FlatList` | `ImmutableArray<'T>` | `flatList { }` |
+| `EquatableArray` | `EquatableArray<'T>`, an `ImmutableArray<'T>` with value equality, comparison and an empty default value | – |
| `ImmutableList` | `ImmutableList<'T>` | – |
| `Stack` | `ImmutableStack<'T>` | – |
| `Queue` | `ImmutableQueue<'T>` | – |
diff --git a/docsSrc/index.md b/docsSrc/index.md
index 276ad8fe..3bb669d1 100644
--- a/docsSrc/index.md
+++ b/docsSrc/index.md
@@ -9,6 +9,7 @@ F# bindings for [System.Collections.Immutable](https://learn.microsoft.com/en-us
| Module | .NET type | Computation expression |
|---|---|---|
| `FlatList` | `ImmutableArray<'T>` | `flatList { }` |
+| `EquatableArray` | `EquatableArray<'T>`, an `ImmutableArray<'T>` with value equality, comparison and an empty default value | – |
| `ImmutableList` | `ImmutableList<'T>` | – |
| `Stack` | `ImmutableStack<'T>` | – |
| `Queue` | `ImmutableQueue<'T>` | – |
diff --git a/src/FSharp.Collections.Immutable/EquatableArray/EquatableArray.fs b/src/FSharp.Collections.Immutable/EquatableArray/EquatableArray.fs
new file mode 100644
index 00000000..5eed1dfe
--- /dev/null
+++ b/src/FSharp.Collections.Immutable/EquatableArray/EquatableArray.fs
@@ -0,0 +1,354 @@
+namespace FSharp.Collections.Immutable
+
+open System
+open System.Collections
+open System.Collections.Generic
+open System.Collections.Immutable
+open System.Runtime.CompilerServices
+open System.Runtime.InteropServices
+open System.Text.Json
+open System.Text.Json.Serialization
+
+/// The comparer of the elements of an EquatableArray, chosen once per element type: the order of F# generic
+/// comparison, without boxing where a BCL comparer has that order.
+[]
+type internal ElementComparer<'T> private () =
+
+ static let comparer : IComparer<'T> =
+ let elementType = typeof<'T>
+
+ if Type.(=) (elementType, typeof) then
+ // Comparer.Default compares with the current culture; F# compares strings ordinally
+ unbox>(box StringComparer.Ordinal)
+ elif
+ elementType.IsPrimitive
+ || Type.(=) (elementType, typeof)
+ then
+ // On these types Comparer<'T>.Default agrees with F# compare (NaN, signed zero and the extremes included)
+ // and does not box
+ Comparer<'T>.Default
+ else
+ // Unchecked.compare needs no 'comparison' constraint, which a conditional constraint cannot give
+ {
+ new IComparer<'T> with
+ member _.Compare (x, y) = Unchecked.compare x y
+ }
+
+ static member Instance = comparer
+
+/// The nesting of EquatableArray displays on the current thread. %A starts a new formatter for each nested display, so
+/// its own depth limit and cycle check do not reach through them; this counter does.
+[]
+type internal DisplayDepth private () =
+
+ []
+ static val mutable private depth : int
+
+ /// The nesting at which a display shows "...", as %A does at its own default depth limit.
+ static member Limit = 100
+
+ static member Current = DisplayDepth.depth
+ static member Enter () = DisplayDepth.depth <- DisplayDepth.depth + 1
+ static member Exit () = DisplayDepth.depth <- DisplayDepth.depth - 1
+
+[]
+[, "Create")>]
+[)>]
+[]
+type EquatableArray<[] 'T> =
+
+ // The only field, so that the wrapper is as large as one reference
+ val private array : ImmutableArray<'T>
+
+ new (items : ImmutableArray<'T>) = { array = items }
+
+ // A default field comes from Unchecked.defaultof, Array.zeroCreate or a missing JSON member. Every member reads
+ // the elements through Items, so the default state of ImmutableArray is never observable.
+ member this.Items : ImmutableArray<'T> =
+ if this.array.IsDefault then
+ ImmutableArray<'T>.Empty
+ else
+ this.array
+
+ member this.Length : int = if this.array.IsDefault then 0 else this.array.Length
+ member this.IsEmpty : bool = this.array.IsDefaultOrEmpty
+
+ member this.Item
+ with get (index : int) : 'T = this.Items[index]
+
+ member this.GetEnumerator () : ImmutableArray<'T>.Enumerator = this.Items.GetEnumerator ()
+ member this.AsSpan () : ReadOnlySpan<'T> = this.Items.AsSpan ()
+ member this.AsMemory () : ReadOnlyMemory<'T> = this.Items.AsMemory ()
+
+ member this.GetSlice (start : int option, finish : int option) : EquatableArray<'T> =
+ let items = this.Items
+ // The bounds of F# array slices: a missing or negative start means the first element, a missing or too large
+ // finish means the last element, and an empty range gives an empty array
+ let low =
+ match start with
+ | Some index when index >= 0 -> index
+ | _ -> 0
+
+ let high =
+ match finish with
+ | Some index when index < items.Length -> index
+ | _ -> items.Length - 1
+
+ // Compare before subtracting: high - low + 1 overflows for extreme bounds such as Int32.MinValue
+ if high < low then
+ EquatableArray<'T> ImmutableArray<'T>.Empty
+ else
+ // Both bounds are inside the array here. ImmutableArray.Slice returns the same array for the whole range
+ // and copies any other range.
+ EquatableArray<'T>(items.Slice (low, high - low + 1))
+
+ // The member that C# ranges call. It checks its arguments as ImmutableArray.Slice does.
+ member this.Slice (start : int, length : int) : EquatableArray<'T> = EquatableArray<'T>(this.Items.Slice (start, length))
+
+ member this.Equals (other : EquatableArray<'T>) : bool =
+ let left = this.Items
+ let right = other.Items
+ // The same underlying array is equal to itself without a scan, because EqualityComparer<'T>.Default is
+ // reflexive. SequenceEqual compares the lengths first and does not box the elements.
+ left.Equals right
+ || MemoryExtensions.SequenceEqual (left.AsSpan (), right.AsSpan (), EqualityComparer<'T>.Default)
+
+ override this.Equals (other : objnull) : bool =
+ match other with
+ | :? EquatableArray<'T> as other -> this.Equals other
+ | _ -> false
+
+ override this.GetHashCode () : int =
+ // All elements, not the last 8 as the structural hash of ImmutableArray
+ let mutable hash = HashCode ()
+
+ for item in this.Items.AsSpan () do
+ hash.Add item
+
+ hash.ToHashCode ()
+
+ member this.CompareTo (other : EquatableArray<'T>) : int =
+ let left = this.Items
+ let right = other.Items
+
+ // Length first, as F# compares arrays: arrays of different lengths are ordered without reading an element
+ if left.Length <> right.Length then
+ left.Length.CompareTo right.Length
+ else
+ let comparer = ElementComparer<'T>.Instance
+ let mutable result = 0
+ let mutable index = 0
+
+ while result = 0 && index < left.Length do
+ result <- comparer.Compare (left[index], right[index])
+ index <- index + 1
+
+ result
+
+ // A text, not a list: a value with StructuredFormatDisplay nested in the display of another one prints as "...",
+ // which would hide the children of a syntax tree. %A reads this property through reflection, also when it is
+ // private.
+ member private this.StructuredDisplayText : string =
+ // Each nested display starts a new %A formatter, which resets its depth limit and cycle check. Without a shared
+ // guard, a cycle through a mutable record or a very deep tree recurses until the stack overflows. The guard
+ // shows "..." at its limit, as %A does; the stack check also protects a thread with a small stack.
+ if
+ DisplayDepth.Current >= DisplayDepth.Limit
+ || not (RuntimeHelpers.TryEnsureSufficientExecutionStack ())
+ then
+ "..."
+ else
+ DisplayDepth.Enter ()
+
+ try
+ // Items is never a default array, so its underlying array is never null
+ let items =
+ this.Items
+ |> ImmutableCollectionsMarshal.AsArray
+ |> Unchecked.nonNull
+
+ $"%A{items}"
+ finally
+ DisplayDepth.Exit ()
+
+ override this.ToString () : string = this.StructuredDisplayText
+
+ static member op_Equality (left : EquatableArray<'T>, right : EquatableArray<'T>) : bool = left.Equals right
+ static member op_Inequality (left : EquatableArray<'T>, right : EquatableArray<'T>) : bool = not (left.Equals right)
+
+ static member op_Implicit (source : EquatableArray<'T>) : ImmutableArray<'T> = source.Items
+ static member op_Implicit (source : ImmutableArray<'T>) : EquatableArray<'T> = EquatableArray<'T> source
+
+ interface IEquatable> with
+ ///
+ member this.Equals other = this.Equals other
+
+ interface IComparable> with
+ ///
+ member this.CompareTo other = this.CompareTo other
+
+ interface IComparable with
+ ///
+ member this.CompareTo other =
+ match other with
+ | :? EquatableArray<'T> as other -> this.CompareTo other
+ | null -> 1
+ | _ -> invalidArg (nameof other) "The object is not an EquatableArray of the same element type."
+
+ interface IEnumerable with
+ ///
+ member this.GetEnumerator () : IEnumerator = (this.Items :> IEnumerable).GetEnumerator()
+
+ interface IEnumerable<'T> with
+ ///
+ member this.GetEnumerator () : IEnumerator<'T> = (this.Items :> IEnumerable<'T>).GetEnumerator()
+
+ interface IReadOnlyCollection<'T> with
+ ///
+ member this.Count = this.Length
+
+ interface IReadOnlyList<'T> with
+ ///
+ member this.Item
+ with get index = this.Items[index]
+
+ // Read-only, as on ImmutableArray: FSharp.Core and LINQ take their fast paths (length, copy) for ICollection<'T>
+ // and IList<'T>, not for IReadOnlyList<'T>. The members that write throw.
+ interface ICollection<'T> with
+ ///
+ member this.Count = this.Length
+
+ ///
+ member _.IsReadOnly = true
+
+ ///
+ member this.Contains item = this.Items.Contains item
+
+ ///
+ member this.CopyTo (array, arrayIndex) = this.Items.CopyTo (array, arrayIndex)
+
+ ///
+ member _.Add _ = raise (NotSupportedException ())
+
+ ///
+ member _.Clear () = raise (NotSupportedException ())
+
+ ///
+ member _.Remove _ = raise (NotSupportedException ())
+
+ interface IList<'T> with
+ ///
+ member this.Item
+ with get index = this.Items[index]
+ and set _ _ = raise (NotSupportedException ())
+
+ ///
+ member this.IndexOf item = this.Items.IndexOf item
+
+ ///
+ member _.Insert (_, _) = raise (NotSupportedException ())
+
+ ///
+ member _.RemoveAt _ = raise (NotSupportedException ())
+
+and [] EquatableArray =
+ static member Create<'T> (items : ReadOnlySpan<'T>) : EquatableArray<'T> = EquatableArray<'T>(ImmutableArray.Create<'T> items)
+
+// Absent from the signature: the factory creates it through reflection for each element type
+and [] EquatableArrayJsonConverter<'T> () =
+ inherit JsonConverter> ()
+
+ // A JSON null gives the empty array, the counterpart of the default value
+ override _.HandleNull = true
+
+ override _.Read (reader : byref, _ : Type, options : JsonSerializerOptions) : EquatableArray<'T> =
+ if reader.TokenType = JsonTokenType.Null then
+ EquatableArray<'T> ImmutableArray<'T>.Empty
+ else
+ // The serializer's own handling of ImmutableArray reads the elements, so element converters and naming
+ // policies apply. The non-generic overload, because the generic one on a struct result gives FS3265.
+ match JsonSerializer.Deserialize (&reader, typeof>, options) with
+ | :? ImmutableArray<'T> as items -> EquatableArray<'T> items
+ | _ -> EquatableArray<'T> ImmutableArray<'T>.Empty
+
+ override _.Write (writer : Utf8JsonWriter, value : EquatableArray<'T>, options : JsonSerializerOptions) : unit =
+ JsonSerializer.Serialize (writer, value.Items, options)
+
+and [] EquatableArrayJsonConverterFactory () =
+ inherit JsonConverterFactory ()
+
+ override _.CanConvert (typeToConvert : Type) : bool =
+ typeToConvert.IsGenericType
+ && Type.(=) (typeToConvert.GetGenericTypeDefinition (), typedefof>)
+
+ override _.CreateConverter (typeToConvert : Type, _ : JsonSerializerOptions) : JsonConverter =
+ let converterType =
+ typedefof>.MakeGenericType(typeToConvert.GetGenericArguments ())
+
+ nonNull (Activator.CreateInstance converterType) :?> JsonConverter
+
+[]
+[]
+module EquatableArray =
+
+ // ---- Creation ----
+
+ []
+ []
+ let empty<'T> : EquatableArray<'T> = EquatableArray<'T> ImmutableArray<'T>.Empty
+
+ []
+ let ofArray (array : 'T array) : EquatableArray<'T> =
+ checkNotNull (nameof array) array
+ // The copy is referenced by nothing else, so it is wrapped without a second copy
+ EquatableArray<'T>(ImmutableCollectionsMarshal.AsImmutableArray (Array.copy array))
+
+ []
+ let ofImmutableArray (items : ImmutableArray<'T>) : EquatableArray<'T> = EquatableArray<'T> items
+
+ []
+ let ofList (list : 'T list) : EquatableArray<'T> =
+ EquatableArray<'T>(ImmutableCollectionsMarshal.AsImmutableArray (List.toArray list))
+
+ []
+ let ofSeq (source : seq<'T>) : EquatableArray<'T> =
+ checkNotNull (nameof source) source
+ EquatableArray<'T>(ImmutableArray.CreateRange<'T> source)
+
+ []
+ let singleton (value : 'T) : EquatableArray<'T> = EquatableArray<'T>(ImmutableArray.Create<'T> value)
+
+ []
+ let unsafeOfArray (array : 'T array) : EquatableArray<'T> =
+ // Without the check, null would become a default array and thus silently the empty array
+ checkNotNull (nameof array) array
+ EquatableArray<'T>(ImmutableCollectionsMarshal.AsImmutableArray array)
+
+ // ---- Properties ----
+
+ []
+ let isEmpty (array : EquatableArray<'T>) : bool = array.IsEmpty
+
+ []
+ let length (array : EquatableArray<'T>) : int = array.Length
+
+ // ---- Transformation ----
+
+ []
+ let map (mapping : 'T -> 'U) (array : EquatableArray<'T>) : EquatableArray<'U> =
+ // Items is never a default array, so its underlying array is never null
+ let source =
+ array.Items
+ |> ImmutableCollectionsMarshal.AsArray
+ |> Unchecked.nonNull
+ // Array.map returns a new array that nothing else references, so it is wrapped without a copy
+ EquatableArray<'U>(
+ source
+ |> Array.map mapping
+ |> ImmutableCollectionsMarshal.AsImmutableArray
+ )
+
+ // ---- Conversion ----
+
+ []
+ let toImmutableArray (array : EquatableArray<'T>) : ImmutableArray<'T> = array.Items
diff --git a/src/FSharp.Collections.Immutable/EquatableArray/EquatableArray.fsi b/src/FSharp.Collections.Immutable/EquatableArray/EquatableArray.fsi
new file mode 100644
index 00000000..0d166812
--- /dev/null
+++ b/src/FSharp.Collections.Immutable/EquatableArray/EquatableArray.fsi
@@ -0,0 +1,308 @@
+namespace FSharp.Collections.Immutable
+
+open System
+open System.Collections.Generic
+open System.Collections.Immutable
+open System.Runtime.CompilerServices
+open System.Text.Json.Serialization
+
+///
+/// An immutable array with value semantics: two arrays are equal when they hold equal elements in the same order.
+///
+/// The type is a struct with one field, an . Thus it is
+/// as large as one reference, and a conversion to or from
+/// does not copy the elements.
+///
+///
+/// Equality and the hash code use all elements and compare them with
+/// . Every caller gets this equality: F# equality,
+/// , ,
+/// dictionary and set keys, and the C# equality operators. Elements that are F# arrays compare by reference; use
+/// as the element type to nest arrays.
+///
+///
+/// Comparison puts a shorter array first. Arrays of the same length compare by their first different element, in the
+/// order of F# generic comparison. Strings compare ordinally, never by culture.
+///
+///
+/// The default value is the empty array. The type supports equality and comparison only when its element type does.
+///
+///
+[]
+[, "Create")>]
+[)>]
+[]
+type EquatableArray<[] 'T> =
+ // A struct must show its fields in a signature (FS0314); the field stays private
+ val private array : ImmutableArray<'T>
+
+ /// Wraps without a copy.
+ /// The elements. A default array gives the empty array.
+ new : items : ImmutableArray<'T> -> EquatableArray<'T>
+
+ ///
+ /// Gets the elements as an , without a copy.
+ ///
+ ///
+ /// The value is never a default array. For the default value of this type, it is
+ /// .
+ ///
+ member Items : ImmutableArray<'T>
+
+ /// Gets the number of elements.
+ member Length : int
+
+ /// Gets a value that shows if the array has no elements.
+ member IsEmpty : bool
+
+ /// Gets the element at .
+ /// The zero-based position of the element.
+ ///
+ /// is less than zero, or it is not less than the length.
+ ///
+ member Item : index : int -> 'T with get
+
+ ///
+ /// Returns an enumerator that is a struct, so that a loop does not allocate.
+ ///
+ member GetEnumerator : unit -> ImmutableArray<'T>.Enumerator
+
+ /// Returns a read-only span over the elements, without a copy.
+ member AsSpan : unit -> ReadOnlySpan<'T>
+
+ /// Returns a read-only memory region over the elements, without a copy.
+ member AsMemory : unit -> ReadOnlyMemory<'T>
+
+ ///
+ /// Returns the elements from to . F# slice expressions call this
+ /// method and get the rules of F# array slices.
+ ///
+ ///
+ /// The index of the first element. If there is no value, or the value is less than zero, the slice starts at the
+ /// first element.
+ ///
+ ///
+ /// The index of the last element. If there is no value, or the value is not less than the length, the slice ends at
+ /// the last element.
+ ///
+ ///
+ /// The selected elements. An empty range gives the empty array. A range of all elements gives the same array without
+ /// a copy; other ranges copy their elements.
+ ///
+ ///
+ ///
+ /// let numbers = EquatableArray.ofList [ 1; 2; 3; 4 ]
+ /// numbers[1..2] // [|2; 3|]
+ /// numbers[2..] // [|3; 4|]
+ /// numbers[5..9] // [||]
+ ///
+ ///
+ member GetSlice : start : int option * finish : int option -> EquatableArray<'T>
+
+ ///
+ /// Returns elements from . C# range expressions such as
+ /// array[1..^1] call this method.
+ ///
+ /// The index of the first element.
+ /// The number of elements.
+ ///
+ /// The selected elements. A range of all elements gives the same array without a copy; other ranges copy their
+ /// elements.
+ ///
+ ///
+ /// The range is not inside the array, as for .
+ ///
+ member Slice : start : int * length : int -> EquatableArray<'T>
+
+ ///
+ /// Returns when has equal elements in the same order. The elements
+ /// are compared with .
+ ///
+ /// The array to compare with.
+ member Equals : other : EquatableArray<'T> -> bool
+
+ ///
+ /// Compares the array with : a shorter array comes first, and arrays of the same length
+ /// compare by their first different element in the order of F# generic comparison.
+ ///
+ /// The array to compare with.
+ /// A negative number, zero or a positive number.
+ member CompareTo : other : EquatableArray<'T> -> int
+
+ /// Returns when the arrays have equal elements in the same order.
+ /// C# uses this operator for ==; F# equality calls the same equality.
+ /// The first array.
+ /// The second array.
+ static member op_Equality : left : EquatableArray<'T> * right : EquatableArray<'T> -> bool
+
+ /// Returns when the arrays do not have equal elements in the same order.
+ /// C# uses this operator for !=.
+ /// The first array.
+ /// The second array.
+ static member op_Inequality : left : EquatableArray<'T> * right : EquatableArray<'T> -> bool
+
+ ///
+ /// Gives the elements of as an
+ /// , without a copy and never as a default array.
+ ///
+ /// The array.
+ static member op_Implicit : source : EquatableArray<'T> -> ImmutableArray<'T>
+
+ /// Wraps without a copy. A default array gives the empty array.
+ /// The elements.
+ static member op_Implicit : source : ImmutableArray<'T> -> EquatableArray<'T>
+
+ interface IEquatable>
+ interface IComparable>
+ interface IComparable
+ interface IReadOnlyList<'T>
+ interface IList<'T>
+
+///
+/// Creates values for C# collection expressions.
+///
+///
+/// The class has exactly one method. Collection expressions require one such method on a non-generic builder type, so do
+/// not add a second .
+///
+and [] EquatableArray =
+
+ /// Copies into a new array.
+ /// The elements.
+ /// An array with the elements of , in the same order.
+ static member Create : items : ReadOnlySpan<'T> -> EquatableArray<'T>
+
+///
+/// Converts values to and from JSON arrays with
+/// System.Text.Json.
+///
+///
+///
+/// names this factory in a
+/// , so no registration is necessary.
+///
+///
+/// The JSON is the same as for , and the converters and the
+/// naming policy of the serializer options apply to the elements. The default value is written as an empty JSON array.
+/// A JSON null gives the empty array.
+///
+///
+and [] EquatableArrayJsonConverterFactory =
+ inherit JsonConverterFactory
+
+ /// Creates the factory.
+ new : unit -> EquatableArrayJsonConverterFactory
+
+///
+/// Functions that create, read and transform values.
+///
+[]
+[]
+module EquatableArray =
+
+ // ---- Creation ----
+
+ /// Gets the array without elements.
+ /// The default value of is equal to it.
+ []
+ []
+ val empty<'T> : EquatableArray<'T>
+
+ /// Copies the elements of into a new array.
+ /// The elements.
+ /// An array with the elements of , in the same order.
+ /// is .
+ /// O(n). Later changes to do not change the result.
+ []
+ val ofArray : array : 'T array -> EquatableArray<'T>
+
+ /// Wraps without a copy.
+ /// The elements. A default array gives the empty array.
+ /// An array with the elements of .
+ /// O(1).
+ []
+ val ofImmutableArray : items : ImmutableArray<'T> -> EquatableArray<'T>
+
+ /// Copies the elements of into a new array.
+ /// The elements.
+ /// An array with the elements of , in the same order.
+ /// O(n).
+ []
+ val ofList : list : 'T list -> EquatableArray<'T>
+
+ /// Copies the elements of into a new array.
+ /// The elements.
+ /// An array with the elements of , in the same order.
+ /// is .
+ ///
+ /// is a default .
+ ///
+ ///
+ /// O(n). An given as a sequence is not copied, because it
+ /// cannot change.
+ ///
+ []
+ val ofSeq : source : seq<'T> -> EquatableArray<'T>
+
+ /// Returns an array with one element.
+ /// The element.
+ /// An array that holds only.
+ []
+ val singleton : value : 'T -> EquatableArray<'T>
+
+ ///
+ /// Wraps without a copy, as
+ /// does.
+ ///
+ /// The elements, in an array that nothing writes to again.
+ /// An array with the elements of .
+ /// is .
+ ///
+ /// O(1).
+ ///
+ /// The caller gives the array away. The result stays immutable only while nothing writes to
+ /// . A later write changes the elements, the equality and the hash code of the result, also
+ /// where the result is already a key of a dictionary or an element of a set. Use
+ /// to copy.
+ ///
+ ///
+ []
+ val unsafeOfArray : array : 'T array -> EquatableArray<'T>
+
+ // ---- Properties ----
+
+ /// Returns when has no elements.
+ /// The array.
+ /// O(1).
+ []
+ val isEmpty : array : EquatableArray<'T> -> bool
+
+ /// Returns the number of elements of .
+ /// The array.
+ /// O(1).
+ []
+ val length : array : EquatableArray<'T> -> int
+
+ // ---- Transformation ----
+
+ ///
+ /// Returns a new array with the results of for each element of
+ /// , in the same order.
+ ///
+ /// The function to apply to each element.
+ /// The array.
+ /// O(n).
+ []
+ val map : mapping : ('T -> 'U) -> array : EquatableArray<'T> -> EquatableArray<'U>
+
+ // ---- Conversion ----
+
+ ///
+ /// Returns the elements of as an
+ /// , without a copy.
+ ///
+ /// The array.
+ /// The elements. The value is never a default array.
+ /// O(1).
+ []
+ val toImmutableArray : array : EquatableArray<'T> -> ImmutableArray<'T>
diff --git a/src/FSharp.Collections.Immutable/EquatableArray/EquatableArrayPatterns.fs b/src/FSharp.Collections.Immutable/EquatableArray/EquatableArrayPatterns.fs
new file mode 100644
index 00000000..ed966954
--- /dev/null
+++ b/src/FSharp.Collections.Immutable/EquatableArray/EquatableArrayPatterns.fs
@@ -0,0 +1,27 @@
+namespace FSharp.Collections.Immutable
+
+[]
+module EquatableArrayPatterns =
+
+ // ---- Access ----
+
+ let (|Arr0|_|) (array : EquatableArray<'T>) : bool = array.IsEmpty
+
+ []
+ let (|Arr1|_|) (array : EquatableArray<'T>) : 'T voption = if array.Length = 1 then ValueSome array[0] else ValueNone
+
+ []
+ let (|Arr2|_|) (array : EquatableArray<'T>) : struct ('T * 'T) voption =
+ if array.Length = 2 then
+ ValueSome (struct (array[0], array[1]))
+ else
+ ValueNone
+
+ []
+ let (|Arr3|_|) (array : EquatableArray<'T>) : struct ('T * 'T * 'T) voption =
+ if array.Length = 3 then
+ ValueSome (struct (array[0], array[1], array[2]))
+ else
+ ValueNone
+
+ let (|ArrN|_|) (length : int) (array : EquatableArray<'T>) : bool = array.Length = length
diff --git a/src/FSharp.Collections.Immutable/EquatableArray/EquatableArrayPatterns.fsi b/src/FSharp.Collections.Immutable/EquatableArray/EquatableArrayPatterns.fsi
new file mode 100644
index 00000000..e5e2f023
--- /dev/null
+++ b/src/FSharp.Collections.Immutable/EquatableArray/EquatableArrayPatterns.fsi
@@ -0,0 +1,56 @@
+namespace FSharp.Collections.Immutable
+
+///
+/// Struct partial active patterns that match an by its
+/// length. Code over the children of a syntax node can thus read like a list pattern without a conversion to an F# list.
+///
+/// A match does not allocate. Each length has its own partial pattern, because a multi-case active pattern returns a
+/// on the heap for each match. A length without a pattern of its
+/// own falls through to the next case.
+///
+///
+///
+///
+/// match arguments with
+/// | Arr0 -> "no argument"
+/// | Arr1 single -> "one argument"
+/// | Arr2 (first, second) -> "two arguments"
+/// | Arr3 (first, second, third) -> "three arguments"
+/// | ArrN 4 -> "four arguments"
+/// | _ -> "more arguments"
+///
+///
+[]
+module EquatableArrayPatterns =
+
+ // ---- Access ----
+
+ /// Matches an array without elements. The default value matches.
+ /// The array to match.
+ val (|Arr0|_|) : array : EquatableArray<'T> -> bool
+
+ /// Matches an array with exactly one element and gives that element.
+ /// The array to match.
+ []
+ val (|Arr1|_|) : array : EquatableArray<'T> -> 'T voption
+
+ ///
+ /// Matches an array with exactly two elements and gives them as a struct tuple. The pattern binds the tuple with or
+ /// without the keyword.
+ ///
+ /// The array to match.
+ []
+ val (|Arr2|_|) : array : EquatableArray<'T> -> struct ('T * 'T) voption
+
+ /// Matches an array with exactly three elements and gives them as a struct tuple.
+ /// The array to match.
+ []
+ val (|Arr3|_|) : array : EquatableArray<'T> -> struct ('T * 'T * 'T) voption
+
+ ///
+ /// Matches an array with exactly elements, for the lengths that have no pattern of their
+ /// own. Use the indexer to read the elements.
+ ///
+ /// The number of elements to match, written as the argument of the pattern.
+ /// The array to match.
+ val (|ArrN|_|) : length : int -> array : EquatableArray<'T> -> bool
diff --git a/src/FSharp.Collections.Immutable/FSharp.Collections.Immutable.fsproj b/src/FSharp.Collections.Immutable/FSharp.Collections.Immutable.fsproj
index 293de773..e327e728 100644
--- a/src/FSharp.Collections.Immutable/FSharp.Collections.Immutable.fsproj
+++ b/src/FSharp.Collections.Immutable/FSharp.Collections.Immutable.fsproj
@@ -44,6 +44,10 @@
+
+
+
+
diff --git a/tests/FSharp.Collections.Immutable.CSharp.Tests/CollectionExpressionTests.cs b/tests/FSharp.Collections.Immutable.CSharp.Tests/CollectionExpressionTests.cs
new file mode 100644
index 00000000..5efd3583
--- /dev/null
+++ b/tests/FSharp.Collections.Immutable.CSharp.Tests/CollectionExpressionTests.cs
@@ -0,0 +1,41 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace FSharp.Collections.Immutable.CSharp.Tests;
+
+/// C# collection expressions, which call the factory that CollectionBuilderAttribute names.
+[TestClass, EquatableArrayTestCategory, InteropTestCategory]
+public sealed class CollectionExpressionTests
+{
+ [TestMethod]
+ public void CollectionExpressionCreatesTheArray()
+ {
+ EquatableArray array = [1, 2, 3];
+ Assert.AreEqual(EquatableArrayModule.OfArray(new[] { 1, 2, 3 }), array, "a collection expression must give the elements in order");
+ }
+
+ [TestMethod]
+ public void EmptyCollectionExpressionEqualsTheDefaultValue()
+ {
+ EquatableArray empty = [];
+ Assert.IsTrue(empty.IsEmpty, "[] must give the empty array");
+ Assert.AreEqual(default, empty, "[] must equal the default value");
+ }
+
+ [TestMethod]
+ public void SpreadsKeepTheOrderOfTheElements()
+ {
+ EquatableArray first = [1, 2];
+ int[] second = [4];
+ EquatableArray spread = [.. first, 3, .. second];
+ Assert.AreEqual(EquatableArrayModule.OfArray(new[] { 1, 2, 3, 4 }), spread, "spreads must keep the order of the elements");
+ }
+
+ [TestMethod]
+ public void NestedCollectionExpressionsCompareByElementsAtBothLevels()
+ {
+ EquatableArray> nested = [[1], [2, 3]];
+ EquatableArray> same = [[1], [2, 3]];
+ Assert.AreEqual(2, nested.Length, "the outer array must hold two arrays");
+ Assert.AreEqual(same, nested, "separately built nested arrays must be equal");
+ }
+}
diff --git a/tests/FSharp.Collections.Immutable.CSharp.Tests/ComparisonTests.cs b/tests/FSharp.Collections.Immutable.CSharp.Tests/ComparisonTests.cs
new file mode 100644
index 00000000..3460f621
--- /dev/null
+++ b/tests/FSharp.Collections.Immutable.CSharp.Tests/ComparisonTests.cs
@@ -0,0 +1,39 @@
+using System.Globalization;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace FSharp.Collections.Immutable.CSharp.Tests;
+
+/// Comparison as C# code sees it: sorted collections and the default comparer.
+[TestClass, EquatableArrayTestCategory, ComparisonTestCategory]
+public sealed class ComparisonTests
+{
+ [TestMethod]
+ public void SortedSetOrdersByLengthThenByElements()
+ {
+ EquatableArray[] unsorted = [[3], [1, 2], [1]];
+ var sorted = new SortedSet>(unsorted);
+ EquatableArray[] expected = [[1], [3], [1, 2]];
+ CollectionAssert.AreEqual(expected, sorted.ToArray(), "a SortedSet must order by length first, then by elements");
+ }
+
+ [TestMethod]
+ public void StringsCompareOrdinallyInTheTurkishCulture()
+ {
+ var previous = CultureInfo.CurrentCulture;
+ try
+ {
+ CultureInfo.CurrentCulture = new CultureInfo("tr-TR");
+ EquatableArray lower = ["a"];
+ EquatableArray upper = ["B"];
+ EquatableArray dotted = ["i"];
+ EquatableArray dotless = ["ı"];
+ var comparer = Comparer>.Default;
+ Assert.IsGreaterThan(0, comparer.Compare(lower, upper), "'a' must come after 'B' ordinally, not by culture");
+ Assert.IsLessThan(0, comparer.Compare(dotted, dotless), "'i' must come before 'ı' ordinally, not by culture");
+ }
+ finally
+ {
+ CultureInfo.CurrentCulture = previous;
+ }
+ }
+}
diff --git a/tests/FSharp.Collections.Immutable.CSharp.Tests/ConversionTests.cs b/tests/FSharp.Collections.Immutable.CSharp.Tests/ConversionTests.cs
new file mode 100644
index 00000000..97e13523
--- /dev/null
+++ b/tests/FSharp.Collections.Immutable.CSharp.Tests/ConversionTests.cs
@@ -0,0 +1,55 @@
+using System.Collections.Immutable;
+using System.Globalization;
+using System.Runtime.InteropServices;
+using Microsoft.FSharp.Core;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace FSharp.Collections.Immutable.CSharp.Tests;
+
+/// Conversions, enumeration and the module functions, as C# code uses them.
+[TestClass, EquatableArrayTestCategory, InteropTestCategory]
+public sealed class ConversionTests
+{
+ [TestMethod]
+ public void ImplicitConversionsKeepTheArray()
+ {
+ var items = ImmutableArray.Create(1, 2, 3);
+ EquatableArray array = items;
+ ImmutableArray back = array;
+ Assert.AreSame(ImmutableCollectionsMarshal.AsArray(items), ImmutableCollectionsMarshal.AsArray(array.Items), "the conversion to EquatableArray must not copy");
+ Assert.AreSame(ImmutableCollectionsMarshal.AsArray(items), ImmutableCollectionsMarshal.AsArray(back), "the conversion to ImmutableArray must not copy");
+ }
+
+ [TestMethod]
+ public void ImplicitConversionOfTheDefaultValueGivesAnEmptyImmutableArray()
+ {
+ ImmutableArray converted = default(EquatableArray);
+ Assert.IsFalse(converted.IsDefault, "the default value must become an empty, not a default, ImmutableArray");
+ }
+
+ [TestMethod]
+ public void ForeachAndLinqReadTheElements()
+ {
+ EquatableArray array = [1, 2, 3];
+ var sum = 0;
+ foreach (var item in array)
+ {
+ sum += item;
+ }
+
+ Assert.AreEqual(6, sum, "foreach must read every element");
+ Assert.AreEqual(12, array.Select(item => item * 2).Sum(), "LINQ must read every element");
+ Assert.AreEqual(2, array[1], "the indexer must read the element");
+ }
+
+ [TestMethod]
+ public void ModuleFunctionsHaveCSharpNames()
+ {
+ var array = EquatableArrayModule.OfSeq(new List { 1, 2 });
+ Assert.AreEqual(2, EquatableArrayModule.Length(array), "Length must count the elements");
+ var mapped = EquatableArrayModule.Map(FuncConvert.FromFunc(item => item.ToString(CultureInfo.InvariantCulture)), array);
+ EquatableArray expected = ["1", "2"];
+ Assert.AreEqual(expected, mapped, "Map must apply the function to each element");
+ Assert.IsTrue(EquatableArrayModule.Empty().IsEmpty, "Empty must give the empty array");
+ }
+}
diff --git a/tests/FSharp.Collections.Immutable.CSharp.Tests/EqualityTests.cs b/tests/FSharp.Collections.Immutable.CSharp.Tests/EqualityTests.cs
new file mode 100644
index 00000000..aaf3037f
--- /dev/null
+++ b/tests/FSharp.Collections.Immutable.CSharp.Tests/EqualityTests.cs
@@ -0,0 +1,54 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace FSharp.Collections.Immutable.CSharp.Tests;
+
+/// Equality as C# code sees it: the operators, the default collections and record structs.
+[TestClass, EquatableArrayTestCategory, EqualityTestCategory]
+public sealed class EqualityTests
+{
+ [TestMethod]
+ public void OperatorsCompareTheElements()
+ {
+ EquatableArray left = [1, 2, 3];
+ var right = EquatableArrayModule.OfArray(new[] { 1, 2, 3 });
+ EquatableArray different = [3, 2, 1];
+
+ // Each operator result goes into a variable, so that the test reads the operator, not an assertion of it
+ var equal = left == right;
+ var notEqual = left != right;
+ var equalToDifferent = left == different;
+ Assert.IsTrue(equal, "== must be true for equal elements in the same order");
+ Assert.IsFalse(notEqual, "!= must be false for equal elements in the same order");
+ Assert.IsFalse(equalToDifferent, "== must be false for elements in another order");
+ Assert.AreEqual(left.GetHashCode(), right.GetHashCode(), "equal arrays must have equal hash codes");
+ }
+
+ [TestMethod]
+ public void DefaultValueEqualsTheEmptyArray()
+ {
+ EquatableArray empty = [];
+ var equal = default(EquatableArray) == empty;
+ Assert.IsTrue(equal, "the default value must equal []");
+ }
+
+ [TestMethod]
+ public void DefaultCollectionsFindEqualArrays()
+ {
+ EquatableArray key = [1, 2];
+ var equal = EquatableArrayModule.OfArray(new[] { 1, 2 });
+ var set = new HashSet> { key };
+ Assert.Contains(equal, set, "a HashSet must find an equal array");
+ var dictionary = new Dictionary, string> { [key] = "found" };
+ Assert.AreEqual("found", dictionary[equal], "a Dictionary must find an equal key");
+ }
+
+ [TestMethod]
+ public void RecordStructsCompareTheElements() =>
+ Assert.AreEqual(
+ new Holder("n", [1, 2]),
+ new Holder("n", EquatableArrayModule.OfArray(new[] { 1, 2 })),
+ "a record struct must compare its array member by elements");
+
+ /// A record struct that holds an array, as C# models do.
+ public readonly record struct Holder(string Name, EquatableArray Items);
+}
diff --git a/tests/FSharp.Collections.Immutable.CSharp.Tests/FSharp.Collections.Immutable.CSharp.Tests.csproj b/tests/FSharp.Collections.Immutable.CSharp.Tests/FSharp.Collections.Immutable.CSharp.Tests.csproj
new file mode 100644
index 00000000..627c8f78
--- /dev/null
+++ b/tests/FSharp.Collections.Immutable.CSharp.Tests/FSharp.Collections.Immutable.CSharp.Tests.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net8.0
+ $(AssemblyBaseName).CSharp.Tests
+
+ 12.0
+ enable
+
+ Exe
+ true
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/FSharp.Collections.Immutable.CSharp.Tests/IndexAndRangeTests.cs b/tests/FSharp.Collections.Immutable.CSharp.Tests/IndexAndRangeTests.cs
new file mode 100644
index 00000000..d6b5fbb6
--- /dev/null
+++ b/tests/FSharp.Collections.Immutable.CSharp.Tests/IndexAndRangeTests.cs
@@ -0,0 +1,34 @@
+using System.Runtime.InteropServices;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace FSharp.Collections.Immutable.CSharp.Tests;
+
+/// C# indices and ranges, which use Length, the indexer and Slice.
+[TestClass, EquatableArrayTestCategory, InteropTestCategory]
+public sealed class IndexAndRangeTests
+{
+ private static readonly EquatableArray Numbers = [1, 2, 3, 4];
+
+ [TestMethod]
+ public void IndexFromTheEndReadsTheElement() => Assert.AreEqual(4, Numbers[^1], "^1 must read the last element");
+
+ [TestMethod]
+ public void RangeGivesTheSelectedElements()
+ {
+ EquatableArray inner = [2, 3];
+ Assert.AreEqual(inner, Numbers[1..^1], "1..^1 must give the inner elements");
+ Assert.IsTrue(Numbers[2..2].IsEmpty, "an empty range must give the empty array");
+ }
+
+ [TestMethod]
+ public void RangeOfAllElementsSharesTheArray()
+ {
+ var underlying = ImmutableCollectionsMarshal.AsArray(Numbers.Items);
+ Assert.AreSame(underlying, ImmutableCollectionsMarshal.AsArray(Numbers[..].Items), "a range of all elements must not copy");
+ Assert.AreNotSame(underlying, ImmutableCollectionsMarshal.AsArray(Numbers[1..].Items), "a smaller range must copy");
+ }
+
+ [TestMethod]
+ public void ReversedRangeThrows() =>
+ Assert.ThrowsExactly(() => _ = Numbers[2..1], "a range that ends before its start must throw");
+}
diff --git a/tests/FSharp.Collections.Immutable.CSharp.Tests/JsonTests.cs b/tests/FSharp.Collections.Immutable.CSharp.Tests/JsonTests.cs
new file mode 100644
index 00000000..fa0da205
--- /dev/null
+++ b/tests/FSharp.Collections.Immutable.CSharp.Tests/JsonTests.cs
@@ -0,0 +1,21 @@
+using System.Text.Json;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace FSharp.Collections.Immutable.CSharp.Tests;
+
+/// A JSON round trip, as C# code does it.
+[TestClass, EquatableArrayTestCategory, JsonTestCategory]
+public sealed class JsonTests
+{
+ [TestMethod]
+ public void RecordStructRoundTrips()
+ {
+ var holder = new Holder("n", [1, 2]);
+ var json = JsonSerializer.Serialize(holder);
+ Assert.AreEqual("""{"Name":"n","Items":[1,2]}""", json, "the array member must be written as a JSON array");
+ Assert.AreEqual(holder, JsonSerializer.Deserialize(json), "the record struct must be read back");
+ }
+
+ /// A record struct that holds an array, as C# models do.
+ public readonly record struct Holder(string Name, EquatableArray Items);
+}
diff --git a/tests/FSharp.Collections.Immutable.CSharp.Tests/MSTestSettings.cs b/tests/FSharp.Collections.Immutable.CSharp.Tests/MSTestSettings.cs
new file mode 100644
index 00000000..80d05a95
--- /dev/null
+++ b/tests/FSharp.Collections.Immutable.CSharp.Tests/MSTestSettings.cs
@@ -0,0 +1,3 @@
+// The F# test project sets this in testconfig.json. The MSTest runner reads only .testconfig.json, which
+// MSBuild makes from a testconfig.json in the project folder, so a linked file would be ignored; the attribute is explicit.
+[assembly: Microsoft.VisualStudio.TestTools.UnitTesting.Parallelize(Workers = 0, Scope = Microsoft.VisualStudio.TestTools.UnitTesting.ExecutionScope.MethodLevel)]
diff --git a/tests/FSharp.Collections.Immutable.CSharp.Tests/TestCategories.cs b/tests/FSharp.Collections.Immutable.CSharp.Tests/TestCategories.cs
new file mode 100644
index 00000000..abd510c0
--- /dev/null
+++ b/tests/FSharp.Collections.Immutable.CSharp.Tests/TestCategories.cs
@@ -0,0 +1,40 @@
+// The TestCategoryBaseAttribute descendants that categorize the C# tests, sorted by type name. They give the same
+// categories as the attributes of the same names in the F# test project.
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace FSharp.Collections.Immutable.CSharp.Tests;
+
+/// Categorizes a test of the order of values.
+public sealed class ComparisonTestCategoryAttribute : TestCategoryBaseAttribute
+{
+ ///
+ public override IList TestCategories { get; } = ["Comparison"];
+}
+
+/// Categorizes a test of equality and hash codes.
+public sealed class EqualityTestCategoryAttribute : TestCategoryBaseAttribute
+{
+ ///
+ public override IList TestCategories { get; } = ["Equality"];
+}
+
+/// Categorizes a test of .
+public sealed class EquatableArrayTestCategoryAttribute : TestCategoryBaseAttribute
+{
+ ///
+ public override IList TestCategories { get; } = ["EquatableArray"];
+}
+
+/// Categorizes a test of the members that .NET interfaces, conversions, spans and C# use.
+public sealed class InteropTestCategoryAttribute : TestCategoryBaseAttribute
+{
+ ///
+ public override IList TestCategories { get; } = ["Interop"];
+}
+
+/// Categorizes a test of the conversion to and from JSON.
+public sealed class JsonTestCategoryAttribute : TestCategoryBaseAttribute
+{
+ ///
+ public override IList TestCategories { get; } = ["JSON"];
+}
diff --git a/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayAllocationTests.fs b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayAllocationTests.fs
new file mode 100644
index 00000000..504019c2
--- /dev/null
+++ b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayAllocationTests.fs
@@ -0,0 +1,93 @@
+namespace FSharp.Collections.Immutable.Tests
+
+// Release only: a Debug build does not optimize F# equality and the loops that these tests measure, so their counts
+// would not show the costs of ADR 0002 §3. CI runs both configurations.
+#if !DEBUG
+
+open System
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open FSharp.Collections.Immutable
+
+[]
+type EquatableArrayAllocationTests () =
+
+ /// Counts the bytes that action allocates on this thread in 100 calls. The caller creates the closure before the
+ /// first count. The test project turns off tiered compilation in Release, so the code is fully optimized from the
+ /// first call, and the first call outside the count does the static initialization.
+ static let allocatedBytes (action : unit -> unit) =
+ action ()
+ let before = GC.GetAllocatedBytesForCurrentThread ()
+
+ for _ in 1..100 do
+ action ()
+
+ GC.GetAllocatedBytesForCurrentThread () - before
+
+ static let assertNoAllocation (operation : string) (action : unit -> unit) =
+ Assert.AreEqual (0L, allocatedBytes action, $"%s{operation} must not allocate")
+
+ /// Two equal arrays of 32 elements, built separately.
+ static let pair (element : int -> 'T) =
+ EquatableArray.ofArray (Array.init 32 element), EquatableArray.ofArray (Array.init 32 element)
+
+ []
+ member _.``the counter sees an allocation`` () =
+ // The control of the other tests: a count of 0 must mean no allocation, not a counter that sees nothing.
+ // GC.KeepAlive makes the array escape, so the JIT cannot put it on the stack.
+ Assert.IsTrue (
+ allocatedBytes (fun () -> GC.KeepAlive (Array.zeroCreate 32)) > 0L,
+ "the counter must see a heap allocation"
+ )
+
+ []
+ member _.``F# equality allocates nothing for the elements of ADR 0002 §3`` () =
+ let leftInts, rightInts = pair id
+ let leftStrings, rightStrings = pair string
+ let leftUnions, rightUnions = pair Leaf
+ let leftTuples, rightTuples = pair (fun index -> struct (index, string index))
+ let leftPoints, rightPoints = pair (fun index -> { X = index; Y = -index })
+ assertNoAllocation "= on int elements" (fun () -> ignore (leftInts = rightInts))
+ assertNoAllocation "= on string elements" (fun () -> ignore (leftStrings = rightStrings))
+ assertNoAllocation "= on union elements" (fun () -> ignore (leftUnions = rightUnions))
+ assertNoAllocation "= on struct tuple elements" (fun () -> ignore (leftTuples = rightTuples))
+ assertNoAllocation "= on struct record elements" (fun () -> ignore (leftPoints = rightPoints))
+
+ []
+ member _.``F# equality of a record with an array field allocates nothing`` () =
+ let first = { Name = "x"; Tags = EquatableArray.ofArray (Array.init 32 string) }
+ let second = { Name = "x"; Tags = EquatableArray.ofArray (Array.init 32 string) }
+ assertNoAllocation "= on a record field" (fun () -> ignore (first = second))
+
+ []
+ member _.``hash and CompareTo allocate nothing`` () =
+ let leftInts, rightInts = pair id
+ let leftStrings, rightStrings = pair string
+ assertNoAllocation "hash on int elements" (fun () -> ignore (hash leftInts))
+ assertNoAllocation "CompareTo on int elements" (fun () -> ignore (leftInts.CompareTo rightInts))
+ assertNoAllocation "CompareTo on string elements" (fun () -> ignore (leftStrings.CompareTo rightStrings))
+
+ []
+ member _.``a for loop and a pattern match allocate nothing`` () =
+ let array = EquatableArray.ofArray (Array.init 32 id)
+ let three = EquatableArray.ofArray [| 1; 2; 3 |]
+
+ assertNoAllocation
+ "a for loop"
+ (fun () ->
+ let mutable sum = 0
+
+ for item in array do
+ sum <- sum + item
+
+ ignore sum
+ )
+
+ assertNoAllocation
+ "a pattern match"
+ (fun () ->
+ match three with
+ | Arr3 (first, second, third) -> ignore (first + second + third)
+ | _ -> ()
+ )
+
+#endif
diff --git a/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayComparisonTests.fs b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayComparisonTests.fs
new file mode 100644
index 00000000..86613c68
--- /dev/null
+++ b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayComparisonTests.fs
@@ -0,0 +1,128 @@
+namespace FSharp.Collections.Immutable.Tests
+
+open System
+open System.Collections.Generic
+open System.Collections.Immutable
+open System.Globalization
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open FSharp.Collections.Immutable
+
+[]
+type EquatableArrayComparisonTests () =
+
+ /// Runs action with culture as the current culture of this thread, then restores the previous culture.
+ static let withCulture (culture : string) (action : unit -> unit) =
+ let previous = CultureInfo.CurrentCulture
+
+ try
+ CultureInfo.CurrentCulture <- CultureInfo culture
+ action ()
+ finally
+ CultureInfo.CurrentCulture <- previous
+
+ []
+ member _.``a shorter array comes first, as for F# arrays`` () =
+ Assert.IsTrue (compare (ea [ 9 ]) (ea [ 1; 2 ]) < 0, "the length must decide before the elements")
+ Assert.IsTrue (compare [| 9 |] [| 1; 2 |] < 0, "F# arrays have the same order")
+ Assert.IsTrue (compare [ 9 ] [ 1; 2 ] > 0, "F# lists compare the elements first, so their order differs")
+
+ []
+ member _.``arrays of the same length compare by their first different element`` () =
+ Assert.IsTrue (compare (ea [ 1; 2; 3 ]) (ea [ 1; 3; 0 ]) < 0, "the first different element must decide")
+ Assert.AreEqual (0, compare (ea [ 1; 2 ]) (ea [ 1; 2 ]), "equal arrays must compare as 0")
+ Assert.AreEqual (0, (ea [ 1; 2 ]).CompareTo(ea [ 1; 2 ]), "CompareTo must agree with compare")
+ Assert.IsTrue (
+ Comparer>.Default.Compare(ea [ 1 ], ea [ 2 ]) < 0,
+ "Comparer.Default must use the same order"
+ )
+
+ []
+ member _.``arrays of different lengths sort and build a Set`` () =
+ // ImmutableArray throws here; EquatableArray must not
+ let arrays = [ ea [ 3; 1 ]; ea [ 2 ]; ea [ 1; 2; 3 ]; ea [ 1 ] ]
+ Assert.AreEqual list>(
+ [ ea [ 1 ]; ea [ 2 ]; ea [ 3; 1 ]; ea [ 1; 2; 3 ] ],
+ List.sort arrays,
+ "sorting must order by length, then by elements"
+ )
+ Assert.HasCount (4, Set.ofList arrays, "a Set must hold arrays of different lengths")
+
+ []
+ member _.``strings compare ordinally in every culture`` () =
+ // Comparer.Default would put "a" before "B" and order "i" and "ı" by culture (ADR 0002 §3)
+ for culture in [ "tr-TR"; "en-US"; "" ] do
+ withCulture
+ culture
+ (fun () ->
+ Assert.IsTrue (
+ compare (ea [ "a" ]) (ea [ "B" ]) > 0,
+ $"'a' must come after 'B' ordinally under '%s{culture}'"
+ )
+ Assert.IsTrue (
+ compare (ea [ "i" ]) (ea [ "ı" ]) < 0,
+ $"'i' must come before 'ı' ordinally under '%s{culture}'"
+ )
+ Assert.IsTrue (
+ compare (ea [ "I" ]) (ea [ "i" ]) < 0,
+ $"'I' must come before 'i' ordinally under '%s{culture}'"
+ )
+ )
+
+ []
+ member _.``struct tuples of strings compare in F# order`` () =
+ Assert.IsTrue (
+ compare (ea [ struct ("a", 1) ]) (ea [ struct ("B", 1) ]) > 0,
+ "the strings in the tuples must compare ordinally"
+ )
+ Assert.IsTrue (
+ compare (ea [ struct ("a", 1) ]) (ea [ struct ("a", 2) ]) < 0,
+ "the second tuple element must decide a tie"
+ )
+
+ []
+ member _.``F# arrays as elements compare by contents`` () =
+ Assert.IsTrue (compare (ea [ [| 1 |] ]) (ea [ [| 2 |] ]) < 0, "inner arrays must compare by their elements")
+ Assert.IsTrue (compare (ea [ [| 1; 2 |] ]) (ea [ [| 9 |] ]) > 0, "inner arrays must compare by length first")
+
+ []
+ member _.``NaN comes before every number, unlike in F# lists`` () =
+ // Pinned on purpose: the comparison operators follow CompareTo, which orders NaN first (ADR 0002 §2)
+ Assert.IsTrue (ea [ nan ] < ea [ 1.0 ], "EquatableArray orders NaN before every number")
+ Assert.IsFalse ([ nan ] < [ 1.0 ], "the F# list operators treat NaN as unordered")
+ Assert.AreEqual (0, compare (ea [ nan ]) (ea [ nan ]), "NaN must compare equal to NaN")
+
+ []
+ member _.``the non-generic CompareTo accepts null and rejects other types`` () =
+ let comparable = ea [ 1 ] :> IComparable
+ Assert.AreEqual (1, comparable.CompareTo null, "every array must come after null")
+ Assert.AreEqual (0, comparable.CompareTo (box (ea [ 1 ])), "a boxed equal array must compare as 0")
+
+ Assert.ThrowsExactly(
+ (fun () -> comparable.CompareTo (box [| 1 |]) |> ignore),
+ "an object of another type must be rejected"
+ )
+ |> ignore
+
+ []
+ member _.``the element type parameter carries ComparisonConditionalOn`` () =
+ // A test cannot assert a compile error, so the attribute that gives conditional comparison is read by reflection
+ let typeParameter =
+ typedefof>.GetGenericArguments()
+ |> Array.exactlyOne
+ Assert.IsTrue (
+ typeParameter.IsDefined (typeof, false),
+ "the type must support comparison only when its element type does"
+ )
+
+ []
+ member _.``ImmutableArray elements of different lengths throw ArgumentException`` () =
+ // The element comparison is the F# comparison of the element type; for ImmutableArray it throws on different
+ // lengths, and EquatableArray passes that on (ADR 0002 §2)
+ Assert.ThrowsExactly(
+ (fun () ->
+ compare (ea [ ImmutableArray.Create 1 ]) (ea [ ImmutableArray.Create (1, 2) ])
+ |> ignore
+ ),
+ "the comparison of the ImmutableArray elements must throw as compare on them does"
+ )
+ |> ignore
diff --git a/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayDefaultValueTests.fs b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayDefaultValueTests.fs
new file mode 100644
index 00000000..92f6ae84
--- /dev/null
+++ b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayDefaultValueTests.fs
@@ -0,0 +1,90 @@
+namespace FSharp.Collections.Immutable.Tests
+
+open System
+open System.Collections.Immutable
+open System.Text.Json
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open FSharp.Collections.Immutable
+
+/// Decision 2 of ADR 0002: the default value is the empty array, and the default state of ImmutableArray is never
+/// observable. Unchecked.defaultof, Array.zeroCreate and a missing JSON member produce the default value.
+[]
+type EquatableArrayDefaultValueTests () =
+
+ static let defaultValue = Unchecked.defaultof>
+
+ []
+ member _.``the default value equals the empty array and has its hash code`` () =
+ Assert.AreEqual (EquatableArray.empty, defaultValue, "the default value must equal the empty array")
+ Assert.IsTrue ((defaultValue = EquatableArray.empty), "F# equality must hold too")
+ Assert.AreEqual (hash EquatableArray.empty, hash defaultValue, "equal values must have equal hash codes")
+ Assert.AreEqual (0, compare defaultValue EquatableArray.empty, "the default value must compare equal to the empty array")
+
+ []
+ member _.``the default value has no elements`` () =
+ Assert.AreEqual (0, defaultValue.Length, "the default value must have length 0")
+ Assert.IsTrue (defaultValue.IsEmpty, "the default value must be empty")
+ Assert.IsFalse (defaultValue.Items.IsDefault, "Items must never be a default ImmutableArray")
+ Assert.IsEmpty (defaultValue, "the default value must enumerate no elements")
+
+ []
+ member _.``a for loop over the default value runs zero times`` () =
+ let mutable count = 0
+
+ for _ in defaultValue do
+ count <- count + 1
+
+ Assert.AreEqual (0, count, "a for loop must not throw and must not run")
+
+ []
+ member _.``an element of Array.zeroCreate behaves as the empty array`` () =
+ let element = (Array.zeroCreate> 1)[0]
+ Assert.IsTrue (element.IsEmpty, "a zero-initialized element must be empty")
+ Assert.AreEqual (EquatableArray.empty, element, "a zero-initialized element must equal the empty array")
+
+ []
+ member _.``the indexer of the default value throws IndexOutOfRangeException`` () =
+ Assert.ThrowsExactly(
+ (fun () -> defaultValue[0] |> ignore),
+ "the indexer must throw as for any empty array, not NullReferenceException"
+ )
+ |> ignore
+
+ []
+ member _.``slices of the default value are empty`` () =
+ Assert.IsTrue (defaultValue[0..5].IsEmpty, "an F# slice of the default value must be empty")
+ Assert.IsTrue ((defaultValue.Slice (0, 0)).IsEmpty, "an empty Slice of the default value must be empty")
+
+ Assert.ThrowsExactly(
+ (fun () -> defaultValue.Slice (0, 1) |> ignore),
+ "Slice beyond the length must throw as for any empty array"
+ )
+ |> ignore
+
+ []
+ member _.``conversions of the default value give an empty ImmutableArray`` () =
+ let converted : ImmutableArray = EquatableArray.op_Implicit defaultValue
+ Assert.IsFalse (converted.IsDefault, "the implicit conversion must not give a default ImmutableArray")
+ Assert.IsFalse (
+ (EquatableArray.toImmutableArray defaultValue).IsDefault,
+ "toImmutableArray must not give a default ImmutableArray"
+ )
+ Assert.AreEqual (0, defaultValue.AsSpan().Length, "the span of the default value must be empty")
+
+ []
+ member _.``the default value formats as an empty F# array`` () =
+ Assert.AreEqual ("[||]", $"%A{defaultValue}", "%A must show the default value as [||]")
+ Assert.AreEqual ("[||]", defaultValue.ToString (), "ToString must show the default value as [||]")
+
+ []
+ member _.``the default value serializes as an empty JSON array`` () =
+ Assert.AreEqual ("[]", JsonSerializer.Serialize defaultValue, "the default value must be written as []")
+
+ []
+ member _.``the default value matches Arr0`` () =
+ let matched =
+ match defaultValue with
+ | Arr0 -> true
+ | _ -> false
+
+ Assert.IsTrue (matched, "the default value must match the pattern of the empty array")
diff --git a/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayEqualityTests.fs b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayEqualityTests.fs
new file mode 100644
index 00000000..a2e665b1
--- /dev/null
+++ b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayEqualityTests.fs
@@ -0,0 +1,99 @@
+namespace FSharp.Collections.Immutable.Tests
+
+open System
+open System.Collections.Generic
+open System.Collections.Immutable
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open FSharp.Collections.Immutable
+
+[]
+type EquatableArrayEqualityTests () =
+
+ []
+ member _.``arrays with equal elements in the same order are equal under every caller`` () =
+ // Built separately, so that no caller can pass by comparing the reference of one underlying array
+ let left = EquatableArray.ofArray [| 1; 2; 3 |]
+ let right = EquatableArray.ofList [ 1; 2; 3 ]
+ Assert.IsTrue ((left = right), "F# equality must compare the elements")
+ Assert.IsTrue (left.Equals right, "the typed Equals must compare the elements")
+ Assert.IsTrue (left.Equals (box right), "Equals (obj) must compare the elements")
+ Assert.IsTrue (
+ EqualityComparer>.Default.Equals(left, right),
+ "EqualityComparer.Default must compare the elements"
+ )
+ Assert.IsTrue (
+ (HashIdentity.Structural>).Equals(left, right),
+ "HashIdentity.Structural must compare the elements"
+ )
+ Assert.IsTrue ((struct (left, 1)).Equals(struct (right, 1)), "ValueTuple.Equals must compare the elements")
+ Assert.AreEqual (left.GetHashCode (), right.GetHashCode (), "equal arrays must have equal hash codes")
+ Assert.AreEqual (hash left, hash right, "F# hash must agree")
+
+ []
+ member _.``arrays that differ in order, length or an element are not equal`` () =
+ let array = ea [ 1; 2; 3 ]
+ Assert.AreNotEqual (ea [ 3; 2; 1 ], array, "the order of the elements must count")
+ Assert.AreNotEqual (ea [ 1; 2 ], array, "the length must count")
+ Assert.AreNotEqual (ea [ 1; 2; 4 ], array, "each element must count")
+ Assert.IsFalse (array.Equals (box [| 1; 2; 3 |]), "an F# array with the same elements is not an EquatableArray")
+ Assert.IsFalse (array.Equals (box (ImmutableArray.Create (1, 2, 3))), "an ImmutableArray is not an EquatableArray")
+
+ []
+ member _.``separately built equal arrays are one key of a Dictionary and a HashSet`` () =
+ let dictionary = Dictionary, string>()
+ dictionary[ea [ 1; 2 ]] <- "found"
+ Assert.AreEqual ("found", dictionary[EquatableArray.ofArray [| 1; 2 |]], "a Dictionary must find an equal key")
+
+ // Fully qualified: the HashSet of FSharp.Collections.Immutable abbreviates ImmutableHashSet
+ let set =
+ System.Collections.Generic.HashSet> [ ea [ 1; 2 ]; EquatableArray.ofArray [| 1; 2 |] ]
+
+ Assert.ContainsSingle (set, "a HashSet must keep equal arrays as one element")
+ |> ignore
+
+ []
+ member _.``equality compares elements with their own equality`` () =
+ let first = String ([| 'a'; 'b' |])
+ let second = String ([| 'a'; 'b' |])
+ Assert.AreNotSame (first, second, "the test needs two string instances")
+ Assert.AreEqual (ea [ first ], ea [ second ], "equal strings in different instances must be equal elements")
+
+ []
+ member _.``a record field and a recursive union compare the elements`` () =
+ let document () = { Name = "x"; Tags = EquatableArray.ofList [ "a"; "b" ] }
+ Assert.AreEqual (document (), document (), "generated record equality must compare the array elements")
+
+ let tree () = Branch (ea [ Leaf 1; Branch (ea [ Leaf 2; Leaf 3 ]) ])
+ Assert.AreEqual (tree (), tree (), "generated union equality must compare the array elements at every level")
+ Assert.AreEqual (hash (tree ()), hash (tree ()), "equal trees must have equal hash codes")
+ Assert.AreNotEqual (Branch (ea [ Leaf 1 ]), tree (), "trees with different children must not be equal")
+
+ []
+ member _.``the hash code reads every element once, from first to last`` () =
+ let asked = List()
+ let array = EquatableArray.ofList [ for number in 0..19 -> HashProbe (number, asked) ]
+ array.GetHashCode () |> ignore
+ CollectionAssert.AreEqual (Array.init 20 id, asked.ToArray (), "the hash must read all elements, not the last 8 only")
+
+ []
+ member _.``arrays that differ only in the first of 40 elements have different hash codes`` () =
+ let first = EquatableArray.ofList [ for number in 0..39 -> number ]
+ let second = EquatableArray.ofList [ for number in 0..39 -> if number = 0 then -1 else number ]
+ Assert.AreNotEqual (first.GetHashCode (), second.GetHashCode (), "the hash must use the first element too")
+
+ []
+ member _.``NaN elements are equal, unlike in F# lists`` () =
+ // Pinned on purpose: the elements are compared with EqualityComparer.Default, where NaN equals NaN (ADR 0002 §2)
+ Assert.IsTrue ((ea [ nan ] = ea [ nan ]), "EquatableArray compares NaN elements with Double.Equals")
+ Assert.IsFalse (([ nan ] = [ nan ]), "an F# list compares NaN elements with F# equality, where NaN is not equal to NaN")
+
+ []
+ member _.``the element type parameter carries EqualityConditionalOn`` () =
+ // A test cannot assert a compile error, so the attribute that gives conditional equality is read by reflection
+ let typeParameter =
+ typedefof>.GetGenericArguments()
+ |> Array.exactlyOne
+ Assert.IsTrue (
+ typeParameter.IsDefined (typeof, false),
+ "the type must support equality only when its element type does"
+ )
diff --git a/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayFormattingTests.fs b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayFormattingTests.fs
new file mode 100644
index 00000000..7ac92343
--- /dev/null
+++ b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayFormattingTests.fs
@@ -0,0 +1,68 @@
+namespace FSharp.Collections.Immutable.Tests
+
+open System
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open FSharp.Collections.Immutable
+
+[]
+type EquatableArrayFormattingTests () =
+
+ /// Asserts that %A and ToString show the array as %A shows the F# array with the same elements.
+ static let assertShownAsArray (items : 'T array) (message : string) =
+ let expected = $"%A{items}"
+ let array = EquatableArray.ofArray items
+ Assert.AreEqual (expected, $"%A{array}", $"%%A: %s{message}")
+ Assert.AreEqual (expected, array.ToString (), $"ToString: %s{message}")
+
+ []
+ member _.``%A and ToString show the elements in F# array notation`` () =
+ assertShownAsArray [| 1; 2; 3 |] "numbers must be shown as [|1; 2; 3|]"
+ assertShownAsArray Array.empty "the empty array must be shown as [||]"
+ assertShownAsArray [| "a"; "b" |] "strings must be shown with quotes"
+ assertShownAsArray [| Leaf 1; Leaf 2 |] "union cases must be shown as %A shows them"
+ Assert.AreEqual ("[|1; 2|]", (ea [ 1; 2 ]).ToString(), "ToString must give the text that the first consumer gives")
+
+ []
+ member _.``a nested array shows its children`` () =
+ let nested = ea [ ea [ 1; 2 ]; EquatableArray.empty ]
+ Assert.AreEqual ("[|[|1; 2|]; [||]|]", $"%A{nested}", "a nested array must show its elements, not '...'")
+
+ []
+ member _.``a cycle through a mutable record stops with ... instead of overflowing the stack`` () =
+ let node = { Children = EquatableArray.empty }
+ node.Children <- EquatableArray.singleton node
+ // Object.ToString is annotated as nullable; this override never returns null
+ Assert.Contains ("...", nonNull (node.Children.ToString ()), "ToString must stop the cycle with '...'")
+ Assert.Contains ("...", $"%A{node}", "%A must stop the cycle with '...'")
+
+ []
+ member _.``a tree deeper than the depth limit ends in ... and a shallower tree is shown completely`` () =
+ let deepen (depth : int) =
+ let mutable tree = Leaf 0
+
+ for _ in 1..depth do
+ tree <- Branch (EquatableArray.singleton tree)
+
+ tree
+
+ let deep = $"%A{deepen 10_000}"
+ Assert.Contains ("...", deep, "a tree deeper than the limit must end in '...'")
+ Assert.DoesNotContain ("Leaf 0", deep, "the leaf below the limit must not be shown")
+ Assert.Contains ("Leaf 0", $"%A{deepen 50}", "a tree inside the limit must be shown down to its leaf")
+ Assert.AreEqual ("[|[|1|]|]", (ea [ ea [ 1 ] ]).ToString(), "a display after a deep one must start at depth 0 again")
+
+ []
+ member _.``a record field shows the elements`` () =
+ let document = { Name = "x"; Tags = ea [ "a"; "b" ] }
+ Assert.Contains ("""Tags = [|"a"; "b"|]""", $"%A{document}", "the field must show the elements of the array")
+
+ []
+ member _.``an array longer than 100 elements is shown as %A shows long F# arrays`` () =
+ let items = Array.init 150 id
+ assertShownAsArray items "the display must stop after 100 elements, as for an F# array"
+ StringAssert.EndsWith (
+ (EquatableArray.ofArray items).ToString(),
+ "...|]",
+ StringComparison.Ordinal,
+ "the display must end with '...'"
+ )
diff --git a/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayInteropTests.fs b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayInteropTests.fs
new file mode 100644
index 00000000..f651a2dc
--- /dev/null
+++ b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayInteropTests.fs
@@ -0,0 +1,197 @@
+namespace FSharp.Collections.Immutable.Tests
+
+open System
+open System.Collections.Generic
+open System.Collections.Immutable
+open System.Linq
+open System.Reflection
+open System.Runtime.CompilerServices
+open System.Runtime.InteropServices
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open FSharp.Collections.Immutable
+
+[]
+type EquatableArrayInteropTests () =
+
+ // Methods that take one type and get the other: F# applies op_Implicit silently at a method argument
+ static member private TakeImmutable (items : ImmutableArray) : ImmutableArray = items
+ static member private TakeEquatable (array : EquatableArray) : EquatableArray = array
+
+ []
+ member _.``the constructor and Items keep the underlying array`` () =
+ let items = ImmutableArray.Create (1, 2, 3)
+ // EquatableArray here is the constructor of the struct, although a non-generic class of the same name exists
+ let array = EquatableArray items
+ Assert.AreSame(underlying items, underlying array.Items, "the constructor and Items must not copy")
+
+ []
+ member _.``the implicit conversions keep the underlying array`` () =
+ let items = ImmutableArray.Create (1, 2, 3)
+ let array = EquatableArrayInteropTests.TakeEquatable items
+ Assert.AreSame(
+ underlying items,
+ underlying array.Items,
+ "the conversion to EquatableArray must not copy"
+ )
+ let back = EquatableArrayInteropTests.TakeImmutable array
+ Assert.AreSame(underlying items, underlying back, "the conversion to ImmutableArray must not copy")
+
+ []
+ member _.``the implicit conversions never expose a default ImmutableArray`` () =
+ let fromDefault = EquatableArrayInteropTests.TakeEquatable Unchecked.defaultof>
+ Assert.IsTrue (fromDefault.IsEmpty, "a default ImmutableArray must become the empty array")
+ let toImmutable = EquatableArrayInteropTests.TakeImmutable Unchecked.defaultof>
+ Assert.IsFalse (toImmutable.IsDefault, "the default value must become an empty, not a default, ImmutableArray")
+
+ []
+ member _.``CollectionBuilderAttribute names the factory class, which has exactly one Create`` () =
+ let attribute =
+ typedefof>.GetCustomAttributes(typeof, false)
+ |> Array.exactlyOne
+ :?> CollectionBuilderAttribute
+
+ Assert.AreEqual(
+ typeof,
+ attribute.BuilderType,
+ "C# collection expressions must use the factory class"
+ )
+ Assert.AreEqual ("Create", attribute.MethodName, "C# collection expressions must call Create")
+
+ let creates =
+ typeof.GetMethods(BindingFlags.Public ||| BindingFlags.Static)
+ |> Array.filter (fun method -> method.Name = "Create")
+
+ // F# collection expressions under RFC FS-1342 accept exactly one such method
+ Assert.HasCount (1, creates, "the factory class must have exactly one Create")
+
+ []
+ member _.``Create copies the span`` () =
+ let source = [| 1; 2 |]
+ let array = EquatableArray.Create (ReadOnlySpan source)
+ Assert.AreEqual (ea [ 1; 2 ], array, "Create must keep the elements")
+ Assert.AreNotSame(source, underlying array.Items, "Create must copy the span")
+
+ []
+ member _.``F# slices follow the rules of F# array slices`` () =
+ let source = [| 1; 2; 3; 4 |]
+ let array = EquatableArray.ofArray source
+
+ let check (expected : int array) (actual : EquatableArray) (message : string) =
+ Assert.AreEqual (EquatableArray.ofArray expected, actual, message)
+
+ check source[1..2] array[1..2] "a range inside the array"
+ check source[..1] array[..1] "a range without a start"
+ check source[2..] array[2..] "a range without an end"
+ check source[-1 .. 1] array[-1 .. 1] "a negative start must count as the first element"
+ check source[2..10] array[2..10] "a finish beyond the end must count as the last element"
+ check source[5..9] array[5..9] "a range after the end must be empty"
+ check source[2..1] array[2..1] "a reversed range must be empty"
+
+ []
+ member _.``F# slices with extreme reversed bounds are empty`` () =
+ // The length of such a range overflows when it is computed before the bounds are compared
+ let array = ea [ 1; 2; 3; 4 ]
+ Assert.IsTrue (array[2 .. Int32.MinValue].IsEmpty, "a finish of Int32.MinValue must give the empty array")
+ Assert.IsTrue (array[Int32.MaxValue .. Int32.MinValue].IsEmpty, "the most reversed range must give the empty array")
+ Assert.IsTrue (array[Int32.MaxValue ..].IsEmpty, "a start of Int32.MaxValue must give the empty array")
+
+ []
+ member _.``an F# slice of all elements shares the array and other slices copy`` () =
+ let array = ea [ 1; 2; 3; 4 ]
+ Assert.AreSame(
+ underlying array.Items,
+ underlying array[*].Items,
+ "a slice of all elements must not copy"
+ )
+ Assert.AreSame(
+ underlying array.Items,
+ underlying array[0..3].Items,
+ "a range of all elements must not copy"
+ )
+ Assert.AreNotSame(underlying array.Items, underlying array[1..2].Items, "a smaller range must copy")
+
+ []
+ member _.``Slice follows ImmutableArray.Slice`` () =
+ let array = ea [ 1; 2; 3; 4 ]
+ Assert.AreEqual (ea [ 2; 3 ], array.Slice (1, 2), "Slice must take length elements from start")
+ Assert.AreSame(
+ underlying array.Items,
+ underlying (array.Slice (0, 4)).Items,
+ "a Slice of all elements must not copy"
+ )
+ Assert.IsTrue ((array.Slice (4, 0)).IsEmpty, "an empty range at the end must be valid")
+
+ Assert.ThrowsExactly(
+ (fun () -> array.Slice (3, 2) |> ignore),
+ "a range beyond the end must throw, as C# ranges expect"
+ )
+ |> ignore
+
+ Assert.ThrowsExactly(
+ (fun () -> array.Slice (-1, 1) |> ignore),
+ "a negative start must throw"
+ )
+ |> ignore
+
+ []
+ member _.``the C# equality operators agree with Equals`` () =
+ let array = ea [ 1; 2 ]
+ let equal = EquatableArray.ofArray [| 1; 2 |]
+ let different = ea [ 2; 1 ]
+ Assert.IsTrue (EquatableArray.op_Equality(array, equal), "== must be true for equal arrays")
+ Assert.IsFalse (EquatableArray.op_Inequality(array, equal), "!= must be false for equal arrays")
+ Assert.IsFalse (EquatableArray.op_Equality(array, different), "== must be false for different arrays")
+ Assert.IsTrue (EquatableArray.op_Inequality(array, different), "!= must be true for different arrays")
+
+ []
+ member _.``IReadOnlyList gives the count, the elements and their order`` () =
+ let list = ea [ 10; 20; 30 ] :> IReadOnlyList
+ Assert.HasCount (3, list, "the count must be the length")
+ Assert.AreEqual (20, list[1], "the indexer must give the element")
+ CollectionAssert.AreEqual ([| 10; 20; 30 |], Seq.toArray list, "the enumeration must keep the order")
+
+ []
+ member _.``IList is read-only`` () =
+ let list = ea [ 10; 20; 30 ] :> IList
+ Assert.IsTrue (list.IsReadOnly, "the array must report that it is read-only")
+ Assert.AreEqual (1, list.IndexOf 20, "IndexOf must find the element")
+ Assert.IsTrue (list.Contains 30, "Contains must find the element")
+ let target = Array.zeroCreate 4
+ list.CopyTo (target, 1)
+ CollectionAssert.AreEqual ([| 0; 10; 20; 30 |], target, "CopyTo must copy the elements to the index")
+
+ let writers : (string * (unit -> unit)) list = [
+ "Add", (fun () -> list.Add 40)
+ "Clear", (fun () -> list.Clear ())
+ "Remove", (fun () -> list.Remove 10 |> ignore)
+ "Insert", (fun () -> list.Insert (0, 5))
+ "RemoveAt", (fun () -> list.RemoveAt 0)
+ "the indexer setter", (fun () -> list[0] <- 5)
+ ]
+
+ for name, write in writers do
+ Assert.ThrowsExactly(
+ (fun () -> write ()),
+ $"%s{name} must throw, because the array is read-only"
+ )
+ |> ignore
+
+ []
+ member _.``AsSpan and AsMemory show the elements without a copy`` () =
+ let array = ea [ 1; 2; 3 ]
+ CollectionAssert.AreEqual ([| 1; 2; 3 |], array.AsSpan().ToArray(), "the span must show the elements")
+ let mutable segment = ArraySegment()
+ Assert.IsTrue (MemoryMarshal.TryGetArray (array.AsMemory (), &segment), "the memory must be backed by an array")
+ Assert.AreSame(underlying array.Items, segment.Array, "the memory must not copy the elements")
+
+ []
+ member _.``FSharp.Core and LINQ read the elements`` () =
+ let array = ea [ 1; 2; 3 ]
+ Assert.AreEqual (3, Seq.length array, "Seq.length must count the elements")
+ CollectionAssert.AreEqual ([| 1; 2; 3 |], Seq.toArray array, "Seq.toArray must copy the elements in order")
+ Assert.AreEqual (3, Enumerable.Count array, "Enumerable.Count must count the elements")
+ CollectionAssert.AreEqual (
+ [| 2; 4; 6 |],
+ array.Select(fun item -> item * 2).ToArray(),
+ "LINQ must enumerate the elements"
+ )
diff --git a/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayJsonTests.fs b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayJsonTests.fs
new file mode 100644
index 00000000..71085484
--- /dev/null
+++ b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayJsonTests.fs
@@ -0,0 +1,181 @@
+namespace FSharp.Collections.Immutable.Tests
+
+open System
+open System.Collections.Generic
+open System.Collections.Immutable
+open System.Text.Json
+open System.Text.Json.Serialization
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open FSharp.Collections.Immutable
+
+/// The serializer options of the tests and a reader that suits F# nullness.
+module internal JsonText =
+
+ /// Plain System.Text.Json, without any registration.
+ let plain = JsonSerializerOptions ()
+
+ /// FSharp.SystemTextJson with its defaults.
+ let fsharp = JsonFSharpOptions.Default().ToJsonSerializerOptions()
+
+ /// FSharp.SystemTextJson with skippable option fields.
+ let skippable =
+ JsonFSharpOptions
+ .Default()
+ .WithSkippableOptionFields(SkippableOptionFields.Always, deserializeNullAsNone = true)
+ .ToJsonSerializerOptions()
+
+ /// Every option set, with a name for the failure messages.
+ let all = [
+ "plain System.Text.Json", plain
+ "FSharp.SystemTextJson", fsharp
+ "FSharp.SystemTextJson with skippable options", skippable
+ ]
+
+ /// Reads a JSON text through the non-generic overload: the generic one returns a value that can be null, which
+ /// F# nullness rejects for a struct result (FS3265).
+ let read<'T> (options : JsonSerializerOptions) (json : string) : 'T =
+ match JsonSerializer.Deserialize (json, typeof<'T>, options) with
+ | :? 'T as value -> value
+ | _ -> failwith $"The JSON %s{json} did not give a value of type %s{typeof<'T>.Name}."
+
+ /// Writes a value as a JSON text.
+ let write (options : JsonSerializerOptions) (value : 'T) : string = JsonSerializer.Serialize (value, options)
+
+[]
+type EquatableArrayJsonTests () =
+
+ []
+ member _.``an array is written as a JSON array and read back`` () =
+ for name, options in JsonText.all do
+ let array = ea [ "a"; "b" ]
+ Assert.AreEqual (
+ """["a","b"]""",
+ JsonText.write options array,
+ $"%s{name}: the array must be written as a JSON array"
+ )
+ Assert.AreEqual (
+ array,
+ JsonText.read> options """["a","b"]""",
+ $"%s{name}: a JSON array must be read back"
+ )
+
+ []
+ member _.``the JSON is the same as for ImmutableArray`` () =
+ Assert.AreEqual (
+ JsonText.write JsonText.plain (ImmutableArray.Create (1, 2)),
+ JsonText.write JsonText.plain (ea [ 1; 2 ]),
+ "a document member can change from ImmutableArray to EquatableArray without a change of the stored JSON"
+ )
+
+ []
+ member _.``a record round-trips`` () =
+ for name, options in JsonText.all do
+ let document = { Name = "x"; Tags = ea [ "a"; "b" ] }
+ let json = JsonText.write options document
+ Assert.AreEqual ("""{"Name":"x","Tags":["a","b"]}""", json, $"%s{name}: the record must be written with a JSON array")
+ Assert.AreEqual (document, JsonText.read options json, $"%s{name}: the record must be read back")
+
+ []
+ member _.``a default member is written as an empty JSON array`` () =
+ for name, options in JsonText.all do
+ let json = JsonText.write options { Name = "x"; Tags = Unchecked.defaultof<_> }
+ Assert.AreEqual ("""{"Name":"x","Tags":[]}""", json, $"%s{name}: the default value must be written as []")
+
+ []
+ member _.``a missing or null member is the empty array under plain System.Text.Json`` () =
+ let missing = JsonText.read JsonText.plain """{"Name":"x"}"""
+ Assert.IsTrue (missing.Tags.IsEmpty, "a missing member must give the default value, which is empty")
+ let explicitNull = JsonText.read JsonText.plain """{"Name":"x","Tags":null}"""
+ Assert.IsTrue (explicitNull.Tags.IsEmpty, "a null member must give the empty array")
+
+ []
+ member _.``a missing or null member is an error of the FSharp.SystemTextJson record converter`` () =
+ // FSharp.SystemTextJson requires every field that is not an option; the converter of the array is not called
+ for name, options in [ "defaults", JsonText.fsharp; "skippable options", JsonText.skippable ] do
+ Assert.ThrowsExactly(
+ (fun () -> JsonText.read options """{"Name":"x"}""" |> ignore),
+ $"%s{name}: a missing member must be an error"
+ )
+ |> ignore
+
+ Assert.ThrowsExactly(
+ (fun () ->
+ JsonText.read options """{"Name":"x","Tags":null}"""
+ |> ignore
+ ),
+ $"%s{name}: a null member must be an error"
+ )
+ |> ignore
+
+ []
+ member _.``a JSON null is the empty array`` () =
+ for name, options in JsonText.all do
+ Assert.IsTrue (
+ (JsonText.read> options "null").IsEmpty,
+ $"%s{name}: null must give the empty array"
+ )
+
+ []
+ member _.``a JSON value that is not an array is an error`` () =
+ Assert.ThrowsExactly(
+ (fun () ->
+ JsonText.read> JsonText.plain """{"a":1}"""
+ |> ignore
+ ),
+ "an object must not be read as an array"
+ )
+ |> ignore
+
+ []
+ member _.``a mutable class and a dictionary value round-trip`` () =
+ for name, options in JsonText.all do
+ let json = JsonText.write options (ClassDocument (Name = "x", Tags = ea [ "a" ]))
+ Assert.AreEqual (
+ ea [ "a" ],
+ (JsonText.read options json).Tags,
+ $"%s{name}: a class property must round-trip"
+ )
+
+ let dictionary = Dictionary>()
+ dictionary["k"] <- ea [ 1; 2 ]
+ let back =
+ JsonText.read>> options (JsonText.write options dictionary)
+ Assert.AreEqual (ea [ 1; 2 ], back["k"], $"%s{name}: a dictionary value must round-trip")
+
+ []
+ member _.``nested arrays, value options, unions and struct tuples round-trip with FSharp.SystemTextJson`` () =
+ let value = {
+ Rows = ea [ ea [ 1; 2 ]; EquatableArray.empty ]
+ Maybe = ValueSome (ea [ 3 ])
+ Shapes = ea [ Circle 1.0; Polygon (ea [ struct (0.0, 1.0) ]) ]
+ }
+
+ for name, options in [ "defaults", JsonText.fsharp; "skippable options", JsonText.skippable ] do
+ let json = JsonText.write options value
+ Assert.AreEqual (value, JsonText.read options json, $"%s{name}: the value must round-trip")
+
+ []
+ member _.``the converters and the naming policy of the options reach the elements`` () =
+ let options = JsonSerializerOptions (PropertyNamingPolicy = JsonNamingPolicy.CamelCase)
+ options.Converters.Add (JsonStringEnumConverter ())
+ let days = ea [ DayOfWeek.Monday; DayOfWeek.Friday ]
+ Assert.AreEqual ("""["Monday","Friday"]""", JsonText.write options days, "an element converter must write the elements")
+ Assert.AreEqual (
+ days,
+ JsonText.read