VERSION 1.0 CLASS
BEGIN
  MultiUse = -1  'True
END
Attribute VB_Name = "Dictionary"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = False
Attribute VB_Exposed = True
Attribute VB_Description = "Collection of keys and items which maps keys to items with a minimum cost O(1)."
'
' Version: 2017/7/04
'
' Collection of keys and items. Maps keys to items with a minimum cost, O(1).
'
' Features :
'  * Cross platform, no dependencies.
'  * Performs better than Scripting.Dictionary or Collection on large sets.
'  * New methods: TryGet, TryAdd, IndexOf and Clone
'  * Provides introspection on each key/item in the debug view.
'  * Preserves the insertion order and provides access to keys and items by index.
'  * Unlike Scripting.Dictionary, the getter raises an error if the key is missing, unless a default value is provided.
'  * Unlike Scripting.Dictionary, the getter doesn't create an entry if a key is not present.
'
' Usage:
'
'  Dim dict As New Dictionary
'
'  ' Add a key/item and raise an error if the key is already present '
'  dict.Add "a", 1
'
'  ' Assign a key/item. Overwrites the item if the key is already present '
'  dict("a") = 2
'  Set dict("b") = New Collection
'
'  ' Get an item or raise an error if the key is not present '
'  Debug.Print dict("a")
'
'  ' Get an item or a default item if the key is not present '
'  Debug.Print dict("b", Default:=3)
'
'  ' Get an item by reference if key is present '
'  Dim value
'  If dict.TryGet("a", value) Then Debug.Print value
'
'  ' Remove an item if key is present '
'  Dim value
'  If dict.Remove("a", value) Then Debug.Print "Removed " & value
'
'  ' Add an item only if the key is not already present '
'  If dict.TryAdd("a", 5) Then Debug.Print "Successfuly added"
'
'  ' Iterate the Keys/Items (Base 1 index) '
'  For i = 1 To dict.Count
'    Debug.Print dict.Keys(i), dict.Items(i)
'  Next
'
'  ' Get the Keys/Items '
'  Debug.Print Join(dict.Keys, ", ")
'  Debug.Print Join(dict.Items, ", ")
'

Option Explicit
Option Base 1

Public Enum VbCompareMethod
  vbBinaryCompare
  vbTextCompare
End Enum

Private Type TThis
  Compare As VbCompareMethod
  Count As Long        ' Count of entries '
  Deleted As Long      ' Count of deleted entries '
  Keys() As Variant    ' Ordered keys (base 1) '
  Items() As Variant   ' Ordered items (base 1) '
  Hashes() As Long     ' Ordered keys hash '
  Slots() As Long      ' Indexes of the next entry / buckets '
End Type

Private this As TThis


Public Property Get CompareMode() As VbCompareMethod
Attribute CompareMode.VB_Description = "Specifies the type of key comparison. Either vbBinaryCompare or vbTextCompare"
  CompareMode = this.Compare
End Property

Public Property Let CompareMode(ByVal Compare As VbCompareMethod)
  If Count And this.Compare <> Compare Then Err.Raise 9, , "Dictionary not empty"
  this.Compare = Compare
End Property


Public Property Get Count() As Long
Attribute Count.VB_Description = "Number of entries"
  Count = this.Count - this.Deleted
End Property


Private Property Get Entries() As Variant()
  If this.Count - this.Deleted Then x_enum Entries
End Property


Public Function Clone() As Dictionary
  Set Clone = New Dictionary
  Clone.x_load this
End Function


Public Sub Add(Key, Optional Item)
  Dim h&, s&, i&  ' hash, slot, index '
  If x_try_add(Key, h, s, i) Then Else Err.Raise 457, , "Key already associated: " & Key
  If IsObject(Item) Then Set this.Items(i) = Item Else this.Items(i) = Item
End Sub


Public Function Exists(Key) As Boolean
Attribute Exists.VB_Description = "Returns True if the key is present, False otherwise."
  Exists = x_try_get(Key, 0&, 0&, 0&)
End Function


Public Function IndexOf(Key) As Long
  If this.Deleted Then x_resize  ' collapse entries if some were removed '
  x_try_get Key, 0&, 0&, IndexOf
End Function


Public Property Get Item(Key, Optional Default)
  Dim h&, s&, i&  ' hash, slot, index '

  If x_try_get(Key, h, s, i) Then
    If IsObject(this.Items(i)) Then Set Item = this.Items(i) Else Item = this.Items(i)
  Else
    If VBA.IsMissing(Default) Then Err.Raise 9, , "Key not found: " & Key
    If IsObject(Default) Then Set Item = Default Else Item = Default
  End If
End Property


Public Property Let Item(Key, Optional Default, Item)
Attribute Item.VB_Description = "Gets or sets an item. When the key is missing, the getter returns the Default value if provided or raises error 422"
Attribute Item.VB_UserMemId = 0
  Dim h&, s&, i&  ' hash, slot, index '
  x_try_add Key, h, s, i
  this.Items(i) = Item
End Property


Public Property Set Item(Key, Optional Default, Item)
  Dim h&, s&, i&  ' hash, slot, index '
  x_try_add Key, h, s, i
  Set this.Items(i) = Item
End Property


Public Function Keys(Optional ByVal Index As Long)
Attribute Keys.VB_Description = "Returns all the keys (base 1 array) or a key at Index (base 1) if provided"
  If this.Deleted Then x_resize  ' collapse entries if some were removed '

  If Index Then   ' return the key at index '
    If Index > this.Count Then Err.Raise 9
    Keys = this.Keys(Index)
  Else            ' return all the keys in a base1 array '
    If this.Count Then x_copy Keys, this.Keys Else Keys = Array()
  End If
End Function


Public Function Items(Optional ByVal Index As Long)
Attribute Items.VB_Description = "Returns all the items (base 1 array) or an item at Index (base 1) if provided"
  If this.Deleted Then x_resize  ' collapse entries if some were removed '

  If Index Then    ' return the value at index '
    If Index > this.Count Then Err.Raise 9
    If IsObject(this.Items(Index)) Then Set Items = this.Items(Index) Else Items = this.Items(Index)
  Else             ' return all the values in a base1 array '
    If this.Count Then x_copy Items, this.Items Else Items = Array()
  End If
End Function


Public Function TryGet(Key, out) As Boolean
  Dim h&, s&, i&  ' hash, slot, index '
  If x_try_get(Key, h, s, i) Then TryGet = True Else Exit Function
  If IsObject(this.Items(i)) Then Set out = this.Items(i) Else out = this.Items(i)
End Function


Public Function TryAdd(Key, Item) As Boolean
  Dim h&, s&, i&  ' hash, slot, index '
  If x_try_add(Key, h, s, i) Then TryAdd = True Else Exit Function
  If IsObject(Item) Then Set this.Items(i) = Item Else this.Items(i) = Item
End Function


Public Function Remove(Key, Optional out) As Boolean
Attribute Remove.VB_Description = "Tries to removes a key/item pair. Returns True if the key was present, false otherwise."
  Dim h&, s&, i&  ' hash, slot, index '
  If x_try_get(Key, h, s, i) Then Remove = True Else Exit Function
  If VBA.IsMissing(out) Then Else If IsObject(this.Items(i)) Then Set out = this.Items(i) Else out = this.Items(i)
  this.Deleted = this.Deleted + 1&
  this.Slots(s) = this.Slots(i)
  this.Slots(i) = 0&
  this.Hashes(i) = 0&
  this.Keys(i) = Empty
  this.Items(i) = Empty
End Function


Public Sub RemoveAll()
Attribute RemoveAll.VB_Description = "Removes all the key/item."
  this.Count = 0&
  this.Deleted = 0&
  Erase this.Keys, this.Items, this.Hashes, this.Slots
End Sub


Private Function x_try_get(Key, h As Long, s As Long, i As Long) As Boolean
  If this.Count Then Else Exit Function

  h = x_hash(LCase$(Key)) Xor -1& ' get negative hash '
  s = UBound(this.Slots) + (h Mod UBound(this.Hashes)) ' get slot '
  Do
    i = this.Slots(s) ' get index '
    If i Then Else Exit Function  ' return if no entry '
    If this.Hashes(i) = h Then If x_equal(Key, this.Keys(i)) Then Exit Do  ' break if match '
    s = i ' next slot '
  Loop

  x_try_get = True
End Function


Private Function x_try_add(Key, h As Long, s As Long, i As Long) As Boolean
  If this.Count Then Else x_resize
  If this.Count >= UBound(this.Keys) Then x_resize

  h = x_hash(LCase$(Key)) Xor -1& ' get negative hash '
  s = UBound(this.Slots) + (h Mod UBound(this.Hashes)) ' get slot '
  Do
    i = this.Slots(s)  ' get index '
    If i Then Else Exit Do  ' break if no entry '
    If this.Hashes(i) = h Then If x_equal(Key, this.Keys(i)) Then Exit Function   ' exit if match '
    s = i ' next slot '
  Loop

  this.Count = this.Count + 1
  this.Keys(this.Count) = Key
  this.Hashes(this.Count) = h
  this.Slots(s) = this.Count
  i = this.Count

  x_try_add = True
End Function


Private Sub x_resize()
  Dim i&, s&, n&

  If this.Deleted Then  ' collapse entries '
    For i = 1 To this.Count
      If this.Hashes(i) Then  ' if entry '
        n = n + 1&
        this.Hashes(n) = this.Hashes(i)
        this.Keys(n) = this.Keys(i)
        If IsObject(this.Items(i)) Then Set this.Items(n) = this.Items(i) Else this.Items(n) = this.Items(i)
      End If
    Next

    this.Count = n
    this.Deleted = 0
    If n Then ReDim Preserve this.Keys(n), this.Items(n), this.Hashes(n) ' truncate / GC objects '
  End If

  n = 5 + this.Count * 1.973737421
  ReDim Preserve this.Keys(n), this.Items(n), this.Hashes(n)
  ReDim this.Slots(n * 2)

  For i = 1 To this.Count
    s = UBound(this.Slots) + (this.Hashes(i) Mod n)  ' get slot '

    Do While this.Slots(s)  ' until empty slot '
      s = this.Slots(s)
    Loop

    this.Slots(s) = i  ' empty slot gets the index '
  Next

End Sub


Private Function x_hash(buffer() As Byte) As Long
  Dim i&
  For i = 1 To UBound(buffer) Step 2
    x_hash = ((x_hash Mod 69208103) + buffer(i - 1)) * 31& + buffer(i)
  Next
End Function


Private Function x_equal(a, b) As Boolean
  x_equal = (VarType(a) = vbString) = (VarType(b) = vbString) And StrComp(a, b, this.Compare) = 0
End Function


Friend Sub x_load(data As TThis)
  this = data
End Sub


Private Sub x_copy(dest, src)
  dest = src
  ReDim Preserve dest(this.Count)
End Sub


Private Sub x_enum(dest())
  Dim i&, n&
  ReDim dest(1 To this.Count - this.Deleted, 1 To 2)

  For i = 1 To this.Count
    If this.Hashes(i) Then
      n = n + 1
      dest(n, 1) = this.Keys(i)
      If IsObject(this.Items(i)) Then Set dest(n, 2) = this.Items(i) Else dest(n, 2) = this.Items(i)
    End If
  Next
End Sub