diff --git a/Tools/stdEnumerator-tests/Probe.bas b/Tools/stdEnumerator-tests/Probe.bas new file mode 100644 index 0000000..eca5deb --- /dev/null +++ b/Tools/stdEnumerator-tests/Probe.bas @@ -0,0 +1,185 @@ +Attribute VB_Name = "EnumeratorProbe" +Option Explicit +Private mSource As Object +Private mEnum As stdEnumerator +Private mCalls As Long + +Public Function RunTests(ByVal source As Object) As String + On Error GoTo Failed + Dim lazy As stdEnumerator, ignored As Variant, errorCode As Long + source.Configure 100 + Set lazy = stdEnumerator.CreateFromIEnumVariant(source) + Check source.Reads() = 0 And source.Opens() = 0, "No constructor reads/cursors" + Check lazy.First(0).Length = 0, "Zero prefix" + Check source.Reads() = 0 And source.Opens() = 0, "Zero prefix does not open source" + Check lazy.First(100).Length = 100, "Prefix" + Check source.Reads() = 100, "Exactly 100 Next calls" + Check source.Active() = 0, "Early return releases cursor" + Check lazy.First(100).Length = 100, "Repeated prefix" + Check source.Reads() = 200 And source.Opens() = 2, "Repeated operations reread source" + On Error Resume Next + ignored = lazy.Item(101) + errorCode = Err.Number + On Error GoTo Failed + Check errorCode <> 0, "Source failure propagates" + Check source.Active() = 0, "Failure releases operation cursor" + Check lazy.Item(1) = 1, "New traversal after failed operation" + source.Configure 0 + Set lazy = stdEnumerator.CreateFromIEnumVariant(source) + Check lazy.Length = 1000, "Full traversal" + Check source.Reads() = 1001 And source.Active() = 0, "Terminal read releases cursor" + Check lazy.Length = 1000, "Repeated length" + Check source.Reads() = 2002, "Length rereads source" + source.Configure 0 + Set lazy = stdEnumerator.CreateFromIEnumVariant(source) + Check lazy.enumNext(ignored), "Procedural start" + Check lazy.First(3).Join(",") = "1,2,3", "Interleaved prefix" + Check lazy.enumNext(ignored), "Procedural continuation" + Check ignored = 2 And source.Reads() = 5, "Independent positions" + lazy.enumRefresh + Check source.Active() = 0, "Refresh releases procedural cursor" + Check lazy.enumNext(ignored), "Refreshed traversal" + Check ignored = 1 And source.Reads() = 6, "Refresh starts fresh source" + lazy.enumRefresh + source.Configure 2 + Set lazy = stdEnumerator.CreateFromIEnumVariant(source) + Check lazy.enumNext(ignored), "First before failure" + Check lazy.enumNext(ignored), "Second before failure" + On Error Resume Next + lazy.enumNext ignored + errorCode = Err.Number + On Error GoTo Failed + Check errorCode <> 0 And source.Active() = 0, "Procedural failure releases cursor" + On Error Resume Next + lazy.enumNext ignored + errorCode = Err.Number + On Error GoTo Failed + Check errorCode <> 0 And source.Reads() = 3, "Failed procedural cursor stays failed" + lazy.enumRefresh + Check lazy.enumNext(ignored), "Refresh recovers from failure" + lazy.enumRefresh + source.Configure 0, 3000, True + Set lazy = stdEnumerator.CreateFromIEnumVariant(source) + Dim prefix As stdEnumerator + Set prefix = lazy.First(100) + Check source.Live() = 100, "Prefix retains only returned results" + Check source.Reads() = 100 And source.Active() = 0, "Prefix stops and releases source" + Set prefix = Nothing + Check source.Live() = 0, "Releasing prefix releases its values" + Dim streamed As Variant, seen As Long + source.Configure 0, 3000, True + For Each streamed In lazy + seen = seen + 1 + If seen = 100 Then Exit For + Next + Check source.Reads() = 100, "Native For Each stops at prefix" + Set streamed = Nothing + Check source.Live() = 0 And source.Active() = 0, "Native early exit releases cursor and items" + source.Configure 0, 3000, True + Set mSource = source + Set mEnum = stdEnumerator.CreateFromIEnumVariant(source) + mCalls = 0 + Check mEnum.countBy(stdCallback.CreateFromModule("EnumeratorProbe", "StreamingPredicate")) = 1500, "Streaming predicate count" + Check mCalls = 3000 And source.Reads() = 3001, "Predicate runs during each read" + Check source.Live() = 0 And source.Peak() <= 3, "Consumed objects are not cached" + Check source.Active() = 0, "Count releases source cursor" + source.Configure 0, 20 + mCalls = 0 + Check mEnum.countBy(stdCallback.CreateFromModule("EnumeratorProbe", "NestedPredicate")) = 10, "Reentrant operation cursors" + Check source.Reads() = 23 And source.Opens() = 2, "Nested prefix has separate cursor" + source.Configure 0, 3 + Dim outer As Variant, inner As Variant, pairs As Long + For Each outer In mEnum + For Each inner In mEnum + pairs = pairs + 1 + Next + Next + Check pairs = 9 And source.Opens() = 4, "Nested native loops have separate cursors" + Check source.Active() = 0, "Nested native loops release cursors" + Set mEnum = Nothing + Set mSource = Nothing + Dim dict As Object + Set dict = CreateObject("Scripting.Dictionary") + dict.Add "a", 1 + dict.Add "b", 2 + Set lazy = stdEnumerator.CreateFromIEnumVariant(dict) + Check lazy.Join(",") = "a,b", "Dictionary keys" + Dim entry As Variant, total As Long + For Each entry In lazy + total = total + 1 + Next + Check total = 2, "Native For Each" + Dim c As New Collection, e As stdEnumerator, i As Long, v As Variant + For i = 1 To 1000 + c.Add i + Next + Set e = stdEnumerator.CreateFromIEnumVariant(c) + Check e.First(0).Length = 0, "First zero" + Check e.First(-1).Length = 0, "First negative" + Check e.First(100).Length = 100, "Prefix length" + Check e.Item(10) = 10, "Repeated item traversal" + Check e.enumNext(v), "Procedural start" + Check v = 1, "Independent cursor" + Check e.First(3).Join(",") = "1,2,3", "Replay" + Check e.enumNext(v), "Procedural continuation" + Check v = 2, "Independent continuation" + Check e.Length = 1000, "Full length" + Check e.Join(",") = stdEnumerator.CreateFromArray(e.AsArray()).Join(","), "Full replay" + Set c = New Collection + Set e = stdEnumerator.CreateFromIEnumVariant(c) + Check e.Length = 0, "Empty source" + c.Add 1 + Check e.Length = 1, "Later traversals observe source changes" + c.Remove 1 + Dim obj As New Collection + obj.Add 42 + c.Add obj + c.Add Null + c.Add Empty + c.Add Array(1, 2) + Set e = stdEnumerator.CreateFromIEnumVariant(c) + Dim actual As Object + Set actual = e.Item(1) + Check actual Is obj, "Object identity" + Check IsNull(e.Item(2)), "Null value" + Check IsEmpty(e.Item(3)), "Empty value" + v = e.Item(4) + Check v(1) = 2, "Array value" + Check e.Length = 4, "Mixed length" + Set e = stdEnumerator.CreateFromIEnumVariant(ThisWorkbook.Worksheets) + Set actual = e.Item(1) + Check actual Is ThisWorkbook.Worksheets(1), "Excel Worksheets" + Set e = stdEnumerator.CreateFromIEnumVariant(ThisWorkbook.Worksheets(1).Cells) + Check e.First(100).Length = 100, "Huge Excel Cells prefix" + RunTests = "PASS: uncached streaming, bounded live values, independent cursors, variants and Excel collections" + Exit Function +Failed: + RunTests = "FAIL line " & Erl & ": " & Err.Number & " " & Err.Description +End Function + +Public Function StreamingPredicate(ByVal item As Object, ByVal index As Long) As Boolean + mCalls = mCalls + 1 + Check mSource.Reads() = mCalls, "Predicate interleaves with Next" + Check mSource.Live() <= 3, "Only current values remain live" + StreamingPredicate = item.Id() Mod 2 = 0 +End Function + +Public Function NestedPredicate(ByVal item As Variant, ByVal index As Long) As Boolean + mCalls = mCalls + 1 + If mCalls = 1 Then Check mEnum.First(2).Join(",") = "1,2", "Nested prefix" + Check item = mCalls, "Nested operation preserves outer position" + NestedPredicate = item Mod 2 = 0 +End Function + +Private Sub Check(ByVal condition As Boolean, ByVal label As String) + If Not condition Then Err.Raise 5, "EnumeratorProbe", label +End Sub + +Public Function RunSuite() As String + On Error GoTo Failed + stdEnumeratorTests.testAll + RunSuite = "PASS: " & Test.Count & " existing assertions" + Exit Function +Failed: + RunSuite = "FAIL: " & Err.Number & " " & Err.Source & " " & Err.Description +End Function diff --git a/Tools/stdEnumerator-tests/README.md b/Tools/stdEnumerator-tests/README.md new file mode 100644 index 0000000..331d90d --- /dev/null +++ b/Tools/stdEnumerator-tests/README.md @@ -0,0 +1,25 @@ +# stdEnumerator Excel regressions + +Run `python Tools/stdEnumerator-tests/run.py` on Windows with Excel and pywin32. +The runner uses a separate hidden Excel process and disposable workbook. It checks +actual bytes for CRLF before every import, monitors only its own Office windows, +dismisses dialogs as failures, and bounds worker execution. It discovers the +installed Office/VBE versions, respects managed AccessVBOM policy, and records, +restores and verifies the exact original registry values/types, including absence. + +The existing stdEnumeratorTests suite runs with a lightweight assertion recorder +to avoid the original Test class's worksheet formatting dependency. The new probes +use a Python COM source with an instrumented IEnumVARIANT to verify: +- Zero constructor reads or cursor acquisitions; exactly 100 reads for First(100). +- Independent cursors for repeated, nested, procedural and native For Each traversals. +- Cursor release on early exit, failure, exhaustion and procedural refresh. +- Predicate callbacks interleaved with source reads during countBy. +- At most three live generated COM values while counting 3,000 values; no retained + source values after the operation. First retains only its 100 returned objects. +- Source changes visible on later traversals, variant/object identity, dictionaries, + Worksheets and a prefix of the full Excel Cells collection. + +Verified on 64-bit Excel 16.0 / VBA 7.1. Windows 32-bit, legacy VBA and Mac runtime +paths remain untested. Mac retains eager enumeration. On Windows, Map, Filter, +Sort and explicit conversion operations still materialise their results; this +change provides streaming source traversal, not a fully lazy transformation pipeline. diff --git a/Tools/stdEnumerator-tests/Test.cls b/Tools/stdEnumerator-tests/Test.cls new file mode 100644 index 0000000..ac957d6 --- /dev/null +++ b/Tools/stdEnumerator-tests/Test.cls @@ -0,0 +1,19 @@ +VERSION 1.0 CLASS +BEGIN + MultiUse = -1 +END +Attribute VB_Name = "Test" +Attribute VB_PredeclaredId = True +Option Explicit +Public Count As Long +Public Sub Topic(ByVal label As String) +End Sub +Public Sub Assert(ByVal label As String, ByVal passed As Boolean) + Count = Count + 1 + If Not passed Then Err.Raise 5, "Test.Assert", label +End Sub +Public Sub ResetTimer() +End Sub +Public Property Get Range() As Object + Set Range = ThisWorkbook.Worksheets(1).Range("A1") +End Property diff --git a/Tools/stdEnumerator-tests/run.py b/Tools/stdEnumerator-tests/run.py new file mode 100644 index 0000000..874ace1 --- /dev/null +++ b/Tools/stdEnumerator-tests/run.py @@ -0,0 +1,301 @@ +"""Run bounded lazy-enumeration regressions in disposable Excel. Requires pywin32. +The parent dismisses owned dialogs and restores exact Office/VBE registry values. +""" +import argparse +import ctypes +from ctypes import wintypes +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import threading +import time +import winreg + +sys.dont_write_bytecode = True +ROOT = Path(__file__).resolve().parents[2] +USER = ctypes.WinDLL("user32", use_last_error=True) +USER.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)] +USER.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int] +USER.PostMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM] +USER.GetDlgCtrlID.argtypes = [wintypes.HWND] +USER.GetDlgCtrlID.restype = ctypes.c_int +USER.GetDlgItem.argtypes = [wintypes.HWND, ctypes.c_int] +USER.GetDlgItem.restype = wintypes.HWND +USER.GetClassNameW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int] +USER.GetWindowTextW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int] +CALLBACK = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) +USER.EnumWindows.argtypes = [CALLBACK, wintypes.LPARAM] +USER.EnumChildWindows.argtypes = [wintypes.HWND, CALLBACK, wintypes.LPARAM] + + +def read_value(path, name): + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, path) as key: + return winreg.QueryValueEx(key, name) + except FileNotFoundError: + return None + + +def restore_value(path, name, original): + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, path) as key: + if original is None: + try: + winreg.DeleteValue(key, name) + except FileNotFoundError: + pass + else: + winreg.SetValueEx(key, name, 0, original[1], original[0]) + if read_value(path, name) != original: + raise RuntimeError("Could not restore " + path + "\\" + name) + + +def worker(host, scratch, phase, url): + import pythoncom + import win32com.client + pythoncom.CoInitialize() + import win32process + existing_pids = set(win32process.EnumProcesses()) + app = doc = None + pidpath = Path(scratch) / "office.pid" + try: + app = win32com.client.DispatchEx(host + ".Application") + pid = wintypes.DWORD() + if host == "Word": + # Word exposes a document window handle without requiring VBE trust. + doc = app.Documents.Add(Visible=False) + hwnd = app.Hwnd if host == "Excel" else doc.ActiveWindow.Hwnd + USER.GetWindowThreadProcessId(int(hwnd), ctypes.byref(pid)) + if pid.value in existing_pids: + app = None + raise RuntimeError("Office returned an existing process; refusing to use it") + pidpath.write_bytes(str(pid.value).encode("ascii")) + app.Visible = False + app.DisplayAlerts = 0 + if host == "Excel": + doc = app.Workbooks.Add() + if phase == "probe": + try: + project = doc.VBProject + access = project.VBComponents.Count >= 0 + except Exception: + access = False + info = {"office": str(app.Version), "vbe": str(app.VBE.Version) if access else None, "access": access} + (Path(scratch) / "probe.json").write_bytes(json.dumps(info).encode("utf-8")) + else: + project = doc.VBProject + count = project.VBComponents.Count + zone = doc.Worksheets(1) + project.VBComponents(zone.CodeName).Name = "TestZone" + for relative in ("src/stdICallable.cls", "src/stdEnumerator.cls", "src/stdCallback.cls", "src/stdLambda.cls", "Tools/stdEnumerator-tests/Test.cls", "tests/stdEnumeratorTests.bas", "Tools/stdEnumerator-tests/Probe.bas"): + raw = (ROOT / relative).read_bytes() + if b"\n" in raw.replace(b"\r\n", b"") or b"\r" in raw.replace(b"\r\n", b""): + raise RuntimeError("CRLF required before import: " + relative) + print("Importing " + relative, flush=True) + project.VBComponents.Import(str(ROOT / relative)) + print("Imported " + relative, flush=True) + # Running invokes the compiler; modal failures are caught by the parent. + macro = "'" + doc.Name + "'!EnumeratorProbe.RunTests" if host == "Excel" else "EnumeratorProbe.RunTests" + print("Running " + macro, flush=True) + from win32com.server.util import wrap, ListEnumerator + from win32com.server.exception import COMException + class Item: + _public_methods_ = ["Id"] + def __init__(self, value): + self.value = value + source.live += 1 + source.peak = max(source.peak, source.live) + def Id(self): + return self.value + def __del__(self): + source.live -= 1 + class Cursor(ListEnumerator): + def __init__(self, data): + super().__init__(data) + source.opens += 1 + source.active += 1 + def __del__(self): + source.active -= 1 + def Next(self, count): + source.reads += 1 + if source.fail_after and self.index >= source.fail_after: + raise COMException("Intentional source failure", scode=-2147467259) + result = super().Next(count) + return [wrap(Item(value)) for value in result] if source.objects else result + class Source: + _public_methods_ = ["Reads", "Configure", "Live", "Peak", "Opens", "Active"] + def __init__(self): + self.live = self.peak = self.active = self.opens = 0 + self.Configure(0) + def _NewEnum(self): + return wrap(Cursor(range(1, self.length + 1)), pythoncom.IID_IEnumVARIANT) + def Reads(self): + return self.reads + def Live(self): + return self.live + def Peak(self): + return self.peak + def Opens(self): + return self.opens + def Active(self): + return self.active + def Configure(self, fail_after, length=1000, objects=False): + self.reads = 0 + self.fail_after = fail_after + self.length = length + self.objects = objects + self.peak = self.live + self.opens = 0 + source = Source() + suite_result = app.Run("'" + doc.Name + "'!EnumeratorProbe.RunSuite") + print("Suite:", suite_result, flush=True) + if not suite_result.startswith("PASS:"): + raise RuntimeError(suite_result) + result = app.Run(macro, wrap(source)) + print(host + ": " + str(result), flush=True) + if not str(result).startswith("PASS:"): + raise RuntimeError(str(result)) + except Exception: + try: + pane = app.VBE.ActiveCodePane + selection = pane.GetSelection() + print("VBE selection: " + repr(selection), flush=True) + print("VBE source: " + str(pane.CodeModule.Lines(selection[0], 1)), flush=True) + except Exception as location_error: + print("Could not capture VBE location: " + str(location_error), flush=True) + raise + finally: + try: + if doc is not None: + doc.Close(False if host == "Excel" else 0) + finally: + if app is not None: + app.Quit() + doc = app = None + pythoncom.CoUninitialize() + + +def bounded_worker(host, scratch, phase, url): + pidpath = Path(scratch) / "office.pid" + if pidpath.exists(): + pidpath.unlink() + exports = Path(scratch) / "exports" + exports.mkdir(exist_ok=True) + environment = dict(os.environ, TEMP=str(exports), TMP=str(exports)) + process = subprocess.Popen([sys.executable, __file__, "--worker", host, scratch, phase, url], env=environment) + owned = None + deadline = time.monotonic() + 90 + dialogs = [] + try: + while process.poll() is None: + if pidpath.exists(): + text = pidpath.read_text() + if text: + owned = int(text) + + @CALLBACK + def inspect(hwnd, unused): + pid = wintypes.DWORD() + USER.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) + if owned and pid.value == owned: + kind = ctypes.create_unicode_buffer(256) + USER.GetClassNameW(hwnd, kind, len(kind)) + if kind.value == "#32770": + parts = [] + + @CALLBACK + def capture(child, ignored): + label = ctypes.create_unicode_buffer(1024) + USER.GetWindowTextW(child, label, len(label)) + if label.value: + parts.append(label.value) + if label.value.replace("&", "") == "OK": + control_id = USER.GetDlgCtrlID(child) + USER.PostMessageW(hwnd, 0x0111, control_id, child) + USER.PostMessageW(child, 0x00F5, 0, 0) + return True + + USER.EnumChildWindows(hwnd, capture, 0) + dialogs.append(" | ".join(parts)) + print("Detected and dismissed test dialog: " + dialogs[-1], flush=True) + # Dismiss only this test instance's dialog; never send global keys. + button = USER.GetDlgItem(hwnd, 1) + if button: + USER.PostMessageW(hwnd, 0x0111, 1, button) + USER.PostMessageW(button, 0x00F5, 0, 0) + USER.PostMessageW(hwnd, 0x0100, 13, 0) + USER.PostMessageW(hwnd, 0x0101, 13, 0) + else: + USER.PostMessageW(hwnd, 0x0010, 0, 0) + USER.ShowWindow(hwnd, 6) + return True + + USER.EnumWindows(inspect, 0) + if dialogs: + raise RuntimeError("Office dialog (failed check): " + repr(dialogs)) + if time.monotonic() > deadline: + raise RuntimeError("Office worker timed out; dialogs=" + repr(dialogs)) + time.sleep(0.05) + if dialogs: + raise RuntimeError("Office dialog (failed check): " + repr(dialogs)) + if process.returncode: + raise RuntimeError(host + " worker failed") + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + if owned: + # Only the PID reported by our DispatchEx-created instance. + subprocess.run(["taskkill", "/PID", str(owned), "/F"], capture_output=True) + + +def main(): + import win32com.client + with tempfile.TemporaryDirectory(prefix="stdEnumerator-") as scratch: + bounded_worker("Excel", scratch, "probe", "") + info = json.loads((Path(scratch) / "probe.json").read_text()) + print("Installed host:", info, flush=True) + version = info["office"] + for hive in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE): + try: + with winreg.OpenKey(hive, fr"Software\Policies\Microsoft\Office\{version}\Excel\Security") as key: + if winreg.QueryValueEx(key, "AccessVBOM")[0] == 0: + raise RuntimeError("Managed policy disables AccessVBOM") + except FileNotFoundError: + pass + changes = [] + try: + settings = [(fr"Software\Microsoft\Office\{version}\Excel\Security", "AccessVBOM", 1)] + # VBE version is read from the actual host once project access is available. + for path, name, value in settings: + old = read_value(path, name) + changes.append((path, name, old)) + print("Original setting:", path, name, old, flush=True) + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, path) as key: + winreg.SetValueEx(key, name, 0, winreg.REG_DWORD, value) + if not info["access"]: + bounded_worker("Excel", scratch, "probe", "") + info = json.loads((Path(scratch) / "probe.json").read_text()) + if not info["access"] or not info["vbe"]: + raise RuntimeError("Cannot determine actual VBE version") + vbe_version = ".".join(str(int(part)) for part in info["vbe"].split(".")) + for name in ("BreakOnAllErrors", "BreakOnServerErrors"): + path = fr"Software\Microsoft\VBA\{vbe_version}\Common" + old = read_value(path, name) + changes.append((path, name, old)) + print("Original error trapping:", path, name, old, flush=True) + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, path) as key: + winreg.SetValueEx(key, name, 0, winreg.REG_DWORD, 0) + bounded_worker("Excel", scratch, "tests", "") + finally: + for path, name, old in reversed(changes): + restore_value(path, name, old) + print("Exact original registry values restored and verified.", flush=True) + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "--worker": + worker(*sys.argv[2:]) + else: + main() diff --git a/changelog.md b/changelog.md index eec22dc..867213c 100644 --- a/changelog.md +++ b/changelog.md @@ -474,3 +474,10 @@ The 2025-2026 entries were audited retrospectively against commits through `1bcb - 2026-11-01 `stdRegex` FEATURE - Added x86 and x64 regex matching using native code thunks. Use Create's `useNative` option to choose the engine: True (default) for native code, or False for VBA. - 2026-11-01 `stdRegex` FEATURE - Add `MatchAllCol` returning unkeyed nested VBA collections (full match at item 1, then numbered captures), with `Nothing` for no matches. Existing match APIs are unchanged. + +- 2026-11-01 `stdEnumerator` FEATURE - On Windows, CreateFromIEnumVariant reads one item per IEnumVARIANT::Next call using a separate cursor for each traversal. Consumed source values are not cached: countBy and Length retain constant traversal state, First(100) retains only its returned prefix, and native For Each streams directly. Nested/repeated operations acquire independent source cursors without a stdCOM dependency. Map, Filter and Sort still materialise their results; Mac retains eager enumeration (#54, #186). +- 2026-11-01 `stdEnumerator` BREAKING - Windows CreateFromIEnumVariant previously read a complete snapshot during construction; it now rereads the live source for each operation, including procedural refresh and native For Each, and reports source-read errors during consumption. Callers needing stable replay, mutation isolation or eager errors must materialise immediately: replace Set e = stdEnumerator.CreateFromIEnumVariant(o) with Set e = stdEnumerator.CreateFromArray(stdEnumerator.CreateFromIEnumVariant(o).AsArray()) before mutation. Signatures, keys, callback contracts and defaults are unchanged (#54). +- 2026-11-01 `stdEnumerator` FIX - First(0) and First with a negative count return an empty enumerator without consuming the source (#54). +- 2026-11-01 `stdEnumerator` BREAKING - First with a non-positive count previously returned the entire source and now returns empty. Callers relying on First(0) or First(-1) to copy all values must use CreateFromArray(e.AsArray()) instead; ordinary positive counts are unchanged (#54). +- 2026-11-01 `stdEnumerator-tests` DEV - Add isolated Excel tests for exact source Next counts, independent/reentrant traversals, repeated reads, cursor release on early exit/failure/exhaustion, bounded live object counts during countBy, native For Each prefixes/nesting, source mutations, variant/object values and a prefix of the full worksheet Cells collection. Restore exact VBA-access/error-trapping registry values and dismiss only owned Office dialogs. Windows 32-bit/legacy VBA and Mac runtime coverage remain unverified (#54). +- 2026-11-01 `stdEnumerator` DEV - Name COM vtable slots with private EIUnknown, EIDispatch and EIEnumVariant enums so each enumCall identifies the interface member being invoked; runtime behavior and public APIs are unchanged (#54, #186). diff --git a/docs.json b/docs.json index 995c16a..4b6b912 100644 --- a/docs.json +++ b/docs.json @@ -4840,8 +4840,7 @@ "todos": [ "Implement the following methods:", "Things we can't do (yet)", - "LongPtr?", - "Implement raw API for working with IEnumVARIANT:" + "LongPtr?" ], "requires": [], "methods": [ @@ -4929,7 +4928,9 @@ { "name": "enumRefresh", "description": "Refresh the dataset to the beginning", - "remarks": [], + "remarks": [ + "For Windows IEnumVARIANT sources, release the procedural cursor; the next enumNext starts a new source traversal." + ], "examples": [], "params": [], "returns": null, @@ -5548,7 +5549,9 @@ { "name": "countBy", "description": "Count the number of times a condition is met", - "remarks": [], + "remarks": [ + "Windows IEnumVARIANT sources are read one item at a time during counting, without retaining consumed values." + ], "examples": [], "params": [ { @@ -5855,7 +5858,7 @@ { "name": "iCount", "type": "Long", - "description": "The number of elements to retrieve", + "description": "The number of elements to retrieve. Non-positive counts return an empty enumerator.", "optional": false, "defaultValue": null, "paramArray": false, @@ -5884,7 +5887,9 @@ "name": "NewEnum", "access": "ReadOnly", "description": "For-each compatibility", - "remarks": [], + "remarks": [ + "Windows IEnumVARIANT sources return a fresh source cursor directly. Other modes retain the Collection fallback." + ], "examples": [ "```vb\r\nDim v as variant\r\nFor each v in myEnum\r\n Debug.Pring v\r\nnext\r\n```" ], @@ -5951,7 +5956,9 @@ "name": "Length", "access": "ReadOnly", "description": "Get the length of the enumerator", - "remarks": [], + "remarks": [ + "Windows IEnumVARIANT sources are fully traversed on every call without caching their values." + ], "examples": [], "params": [], "returns": { @@ -6098,7 +6105,10 @@ { "name": "CreateFromIEnumVariant", "description": "Create an enumerator from an object which supports IEnumVARIANT.", - "remarks": [], + "remarks": [ + "On Windows, each traversal acquires its own source cursor and reads one item at a time without caching. Construction does not read items. Mac retains eager enumeration.", + "Each operation enumerates the source again, so mutations may affect later traversals and source errors occur during consumption. For an immediate snapshot use CreateFromArray(CreateFromIEnumVariant(o).AsArray()). Native For Each streams Windows IEnumVARIANT sources directly; result-producing operations such as Map, Filter and Sort still materialise their results." + ], "examples": [], "params": [ { diff --git a/src/stdEnumerator.cls b/src/stdEnumerator.cls index c9f4b25..ae58393 100644 --- a/src/stdEnumerator.cls +++ b/src/stdEnumerator.cls @@ -182,7 +182,6 @@ Attribute VB_Exposed = False ' [1,2].zip([2,3]).map(e=>e[0]*e[1]) ==> [2,6] 'TODO: Things we can't do (yet) -' take <-- can't do this unless we implement IEnumVARIANT and call Next() method ' tally <-- Would like to do this but can't until we have stdDictionary ["a","b","c","b","a","b"].tally ==> {a:2, b:3, c:1} ' to_dict <-- requires stdDictioanry ["a",1,"b",2].to_dict ==> {a:1, b:2} ' groupBy <-- requires stdDictionary @@ -216,6 +215,35 @@ Attribute VB_Exposed = False Private Declare Function createEmptyArrayVariant Lib "oleaut32" Alias "SafeArrayCreateVector" (Optional ByVal vt As VbVarType = vbVariant , Optional ByVal lLow As Long = 0, Optional ByVal lCount As Long = 0) as Variant() #End If #End If + +#If Not Mac Then +#If VBA7 Then +Private Declare PtrSafe Function DispCallFunc Lib "oleaut32" (ByVal instance As LongPtr, ByVal offset As LongPtr, ByVal convention As Long, ByVal returnType As Integer, ByVal count As Long, ByVal types As LongPtr, ByVal values As LongPtr, ByRef result As Variant) As Long +#Else +Private Declare Function DispCallFunc Lib "oleaut32" (ByVal instance As Long, ByVal offset As Long, ByVal convention As Long, ByVal returnType As Integer, ByVal count As Long, ByVal types As Long, ByVal values As Long, ByRef result As Variant) As Long +#End If +#End If +'Zero-based COM vtable slots, including the inherited IUnknown members. +Private Enum EIUnknown + unkQueryInterface = 0 + unkAddRef = 1 + unkRelease = 2 +End Enum +Private Enum EIDispatch + dispInvoke = 6 +End Enum +Private Enum EIEnumVariant + evNext = 3 + evSkip = 4 + evReset = 5 + evClone = 6 +End Enum +Private Type TEnumGUID + Data1 As Long + Data2 As Long + Data3 As Long + Data4 As Long +End Type Private Enum EnumeratorType FromCallable FromCallableVerbose @@ -231,10 +259,18 @@ Private Type SortStruct iPrev As Long End Type +'Each traversal owns its cursor; consumed source values are never retained here. +Private Type TSourceIteration + Cursor As IUnknown + Started As Boolean + Finished As Boolean + ErrorNumber As Long +End Type Private Type TCurrentIteration Value As Variant Key As Variant Index As Long + Source As TSourceIteration End Type Private Type TThis mode As EnumeratorType @@ -287,6 +323,8 @@ Public Function CreateFromArray(ByVal v As Variant, Optional ByVal iMaxLength As End Function 'Create an enumerator from an object which supports IEnumVARIANT. +'@remark On Windows, each traversal acquires its own source cursor and reads one item at a time without caching. Construction does not read items. Mac retains eager enumeration. +'@remark Each operation enumerates the source again, so mutations may affect later traversals and source errors occur during consumption. For an immediate snapshot use CreateFromArray(CreateFromIEnumVariant(o).AsArray()). Native For Each streams Windows IEnumVARIANT sources directly; result-producing operations such as Map, Filter and Sort still materialise their results. '@constructor '@param o - the object to create the enumerator from. This object should support IEnumVARIANT '@param iMaxLength - Maximum length of enumerator. The enumerator will refuse to go beyond this number of items. Used in order to prevent runaway processes. @@ -413,14 +451,13 @@ End Function '@param iMaxLength - Maximum length of enumerator. The enumerator will refuse to go beyond this number of items. Used in order to prevent runaway processes. '@param v - The data to create the enumerator from. This is either an array, a callable, or an IEnumVARIANT object. Public Sub protInit(ByVal iEnumeratorType As Long, ByVal iMaxLength As Long, ParamArray v() As Variant) + enumRefresh This.mode = iEnumeratorType This.MaxLength = iMaxLength Select Case This.mode Case EnumeratorType.FromIEnumVariant - 'HACK: This is a real work around for the fact that we can't access IEnumVARIANT methods (getNewEnum,getNextVariant,skipNextVariant,cloneEnumVariant,resetEnumVariant) - 'directly. Realistically this may sometimes not even work. A more long term solution would be to call getNewEnum() and then getNextVariant() until we reach the end of the - 'object. This may be implementable after stdCOM is fully implemented. - + #If Mac Then + 'Mac retains the eager fallback; the COM invocation path is Windows-only. Set This.EnumObject = v(0) 'Calculate total length @@ -446,6 +483,9 @@ Public Sub protInit(ByVal iEnumeratorType As Long, ByVal iMaxLength As Long, Par Else This.BaseArray = Array() End If + #Else + Set This.EnumObject = v(0) + #End If Case EnumeratorType.FromArray This.BaseArray = v(0) Case EnumeratorType.FromCallable @@ -466,9 +506,16 @@ End Sub ' Debug.Pring v 'next '``` -'@TODO: Use custom IEnumVARIANT instead of casting to Collection +'@remark Windows IEnumVARIANT sources return a fresh source cursor directly. Other modes retain the Collection fallback. +'@TODO: Use custom IEnumVARIANT instead of casting other source modes to Collection Public Property Get NewEnum() As IUnknown Attribute NewEnum.VB_UserMemId = -4 + #If Not Mac Then + If This.mode = FromIEnumVariant Then + Set NewEnum = getNewEnum(This.EnumObject) + Exit Property + End If + #End If Static oEnumCol As Collection: If oEnumCol Is Nothing Then Set oEnumCol = AsCollection() Set NewEnum = oEnumCol.[_NewEnum] End Property @@ -486,7 +533,7 @@ End Property '``` Public Function enumNext(ByRef nextValue As Variant) As Boolean With This.Current - If NextItem(.Key, .Value, .Index) Then + If NextItem(.Key, .Value, .Index, This.Current.Source) Then If IsObject(.Value) Then Set nextValue = .Value Else @@ -500,9 +547,14 @@ Public Function enumNext(ByRef nextValue As Variant) As Boolean End Function 'Refresh the dataset to the beginning +'@remark For Windows IEnumVARIANT sources, release the procedural cursor; the next enumNext starts a new source traversal. Public Sub enumRefresh() With This.Current - .Value = Null + CopyVariant .Value, Null + Set .Source.Cursor = Nothing + .Source.Started = False + .Source.Finished = False + .Source.ErrorNumber = 0 .Index = 0 .Key = Null End With @@ -511,11 +563,12 @@ End Sub 'Get this enumerator as a collection '@returns - The enumerator's data as a collection Public Function AsCollection() As Collection + Dim sourceIteration As TSourceIteration Dim i As Long: i = 0 Dim v As Variant: v = Null Dim kv As String: kv = "" Dim retCol As Collection: Set retCol = New Collection - While NextItem(kv, v, i) + While NextItem(kv, v, i, sourceIteration) retCol.Add v, kv Wend @@ -526,6 +579,7 @@ End Function '@param iType - The vartype of the array generated '@returns Array - The enumerator's data as an array Public Function AsArray(Optional ByVal iType As VbVarType = vbVarType.vbVariant) As Variant + Dim sourceIteration As TSourceIteration Dim arrLen As Long: arrLen = Length() If arrLen = 0 Then AsArray = CreateEmptyVBArray(iType) @@ -576,11 +630,11 @@ Public Function AsArray(Optional ByVal iType As VbVarType = vbVarType.vbVariant) Dim i As Long: i = 0 Select Case iType Case VbVarType.vbObject - While NextItem(kv, v, i) + While NextItem(kv, v, i, sourceIteration) Set vRet(i) = v Wend Case VbVarType.vbVariant - While NextItem(kv, v, i) + While NextItem(kv, v, i, sourceIteration) If IsObject(v) Then Set vRet(i) = v Else @@ -588,7 +642,7 @@ Public Function AsArray(Optional ByVal iType As VbVarType = vbVarType.vbVariant) End If Wend Case Else - While NextItem(kv, v, i) + While NextItem(kv, v, i, sourceIteration) vRet(i) = v Wend End Select @@ -599,6 +653,7 @@ End Function 'Converts an enumerator of dictionaries to a 2D array '@returns Array2d - 2D Array of data including headers. Number of rows is equal to the length of the enumerator, number of columns is equal to the number of keys in the first item. Public Function AsArray2D(Optional ByVal includeObjects As Boolean = True) + Dim sourceIteration As TSourceIteration Dim vItem Call CopyVariant(vItem, Item(1, True)) @@ -621,7 +676,7 @@ Public Function AsArray2D(Optional ByVal includeObjects As Boolean = True) Dim v As Variant: v = Null Dim kv As Variant: kv = Null i = 0 - While NextItem(kv, v, i) + While NextItem(kv, v, i, sourceIteration) Dim j As Long For j = iKeysLb To iKeysUb If IsObject(v(vKeys(j))) Then @@ -646,13 +701,14 @@ End Function 'Returns the stored enumerator as a dictionary '@returns Object - Dictionary containing enumerator data Public Function AsDictionary() As Object + Dim sourceIteration As TSourceIteration Dim oRet As Object Set oRet = CreateObject("Scripting.Dictionary") Dim v As Variant: v = Null Dim kv As Variant: kv = Null Dim i As Long: i = 0 - While NextItem(kv, v, i) + While NextItem(kv, v, i, sourceIteration) If IsObject(v) Then Set oRet(kv) = v Else @@ -667,10 +723,11 @@ End Function '@param cb as stdICallable<(element: Variant)=>Void> - Callback to call for each item '@returns - This is the enumerator itself for chaining Public Function ForEach(ByVal cb As stdICallable) As stdEnumerator + Dim sourceIteration As TSourceIteration Dim v As Variant: v = Null Dim i As Long: i = 0 Dim kv As Variant: kv = Null - While NextItem(kv, v, i) + While NextItem(kv, v, i, sourceIteration) Call cb.Run(v, kv) Wend Set ForEach = Me @@ -680,11 +737,12 @@ End Function '@param cb as stdICallable<(element: Variant)=>Variant> - Callback to map values to. '@returns - Enumerator containing mapped values Public Function Map(ByVal cb As stdICallable) As stdEnumerator + Dim sourceIteration As TSourceIteration Dim oRet As Collection: Set oRet = New Collection Dim v As Variant: v = Null Dim i As Long: i = 0 Dim kv As Variant: kv = Null - While NextItem(kv, v, i) + While NextItem(kv, v, i, sourceIteration) Call oRet.Add(cb.Run(v, kv)) Wend @@ -695,12 +753,13 @@ End Function '@param cb as stdICallable<(element: Variant)=>Boolean> - callback to test condition for '@returns - Enumerator containing all elements of `Me` for which the given callback returns `true`. Public Function Filter(ByVal cb As stdICallable) As stdEnumerator + Dim sourceIteration As TSourceIteration Dim oRet As Collection: Set oRet = New Collection Dim v As Variant: v = Null Dim i As Long: i = 0 Dim kv As Variant: kv = Null - While NextItem(kv, v, i) + While NextItem(kv, v, i, sourceIteration) If cb.Run(v, kv) Then Call oRet.Add(v) Wend @@ -711,13 +770,14 @@ End Function '@param cb as stdICallable<(element: Variant)=>Variant> - If a callback provided, sorts items based on values returned by the callback. Otherwise sorts items based on values in enumerator '@returns - enumerator of sorted values Public Function Sort(Optional ByVal cb As stdICallable = Nothing) As stdEnumerator + Dim sourceIteration As TSourceIteration If Length > 0 Then Dim arr() As SortStruct Dim iIndex As Long: iIndex = 0 Dim iFirstItem As Long: iFirstItem = 1 Dim val As Variant: val = Null Dim keyVal As Variant: keyVal = Null - While NextItem(keyVal, val, iIndex) + While NextItem(keyVal, val, iIndex, sourceIteration) 'Increment index ReDim Preserve arr(1 To iIndex) @@ -810,16 +870,25 @@ End Function '@param cb as stdICallable<(element: Variant)=>Variant> - If a callback provided, filters out duplicate items based on values returned by the callback. Otherwise filters duplicate item based on their value. '@returns - enumerator of unique values Public Function Unique(Optional ByVal cb As stdICallable = Nothing) As stdEnumerator + Dim sourceIteration As TSourceIteration + Dim innerIteration As TSourceIteration Dim oRet As Collection: Set oRet = New Collection Dim v As Variant: v = Null Dim i As Long: i = 0 Dim kv As Variant: kv = Null Dim comp1 As Variant, comp2 As Variant - While NextItem(kv, v, i) + While NextItem(kv, v, i, sourceIteration) Dim tv As Variant: tv = Null Dim ktv As Variant: ktv = Null Dim ti As Long: ti = i + #If Not Mac Then + If This.mode = FromIEnumVariant Then ti = 0 + #End If + Set innerIteration.Cursor = Nothing + innerIteration.Started = False + innerIteration.Finished = False + innerIteration.ErrorNumber = 0 Dim matchFound As Boolean: matchFound = False 'Obtain comparison token @@ -829,7 +898,8 @@ Public Function Unique(Optional ByVal cb As stdICallable = Nothing) As stdEnumer Call CopyVariant(comp1, cb.Run(v, kv)) End If - Do While NextItem(ktv, tv, ti) + Do While NextItem(ktv, tv, ti, innerIteration) + If ti > i Then 'Obtain 2nd comparison token If cb Is Nothing Then Call CopyVariant(comp2, tv) @@ -839,7 +909,9 @@ Public Function Unique(Optional ByVal cb As stdICallable = Nothing) As stdEnumer matchFound = areVariantsEqual(comp1, comp2) If matchFound Then Exit Do + End If Loop + Set innerIteration.Cursor = Nothing 'If a match isn't found return v If Not matchFound Then Call oRet.Add(v) @@ -865,11 +937,12 @@ End Function '@param obj - Enumerator to concat with that of `me`. '@returns - A concatenated array of values. Public Function Concat(ByVal obj As stdEnumerator) As stdEnumerator + Dim sourceIteration As TSourceIteration Dim oRet As Collection: Set oRet = New Collection Dim v As Variant: v = Null Dim i As Long: i = 0 Dim kv As Variant: v = Null - While NextItem(kv, v, i) + While NextItem(kv, v, i, sourceIteration) Call oRet.Add(v) Wend For Each v In obj.AsCollection @@ -883,6 +956,7 @@ End Function '@param sDelimiter - Enumerator to concat with that of `me`. '@returns - A concatenated array of values. Public Function Join(Optional ByVal sDelimiter As String = ",") As String + Dim sourceIteration As TSourceIteration Dim sRet As String sRet = "" @@ -891,7 +965,7 @@ Public Function Join(Optional ByVal sDelimiter As String = ",") As String Dim i As Long: i = 0 Dim kv As Variant: kv = Null - While NextItem(kv, v, i) + While NextItem(kv, v, i, sourceIteration) sRet = sRet & sDelimiter & v Wend @@ -902,12 +976,13 @@ End Function '@param tv - Element to find. '@returns - Index of element. Public Function indexOf(ByVal tv As Variant) As Long + Dim sourceIteration As TSourceIteration Dim oRet As Collection: Set oRet = New Collection Dim v As Variant: v = Null Dim i As Long: i = 0 Dim kv As Variant: kv = Null - While NextItem(kv, v, i) + While NextItem(kv, v, i, sourceIteration) If areVariantsEqual(tv, v) Then indexOf = i Exit Function @@ -921,12 +996,13 @@ End Function '@param tv - Element to find '@returns - Last index of element. Public Function lastIndexOf(ByVal tv As Variant) As Long + Dim sourceIteration As TSourceIteration Dim oRet As Collection: Set oRet = New Collection Dim v As Variant: v = Null Dim i As Long: i = 0 Dim kv As Variant: kv = Null - While NextItem(kv, v, i) + While NextItem(kv, v, i, sourceIteration) If areVariantsEqual(tv, v) Then lastIndexOf = i End If @@ -944,13 +1020,14 @@ End Function '@param cb as stdICallable<(element: Variant)=>Boolean> - condition to check. '@returns - `True` if all elements satisfy the condition, else `false`. Public Function checkAll(ByVal cb As stdICallable) As Boolean + Dim sourceIteration As TSourceIteration Dim oRet As Collection: Set oRet = New Collection Dim v As Variant: v = Null Dim i As Long: i = 0 Dim kv As Variant: kv = Null checkAll = True - While NextItem(kv, v, i) + While NextItem(kv, v, i, sourceIteration) If Not cb.Run(v, kv) Then checkAll = False Exit Function @@ -962,12 +1039,13 @@ End Function '@param cb as stdICallable<(element: Variant)=>Boolean> - condition to check. '@returns - `True` if any element satisfies the condition, else `false`. Public Function checkAny(ByVal cb As stdICallable) As Boolean + Dim sourceIteration As TSourceIteration Dim oRet As Collection: Set oRet = New Collection Dim v As Variant: v = Null Dim i As Long: i = 0 Dim kv As Variant: kv = Null - While NextItem(kv, v, i) + While NextItem(kv, v, i, sourceIteration) If cb.Run(v, kv) Then checkAny = True Exit Function @@ -980,12 +1058,13 @@ End Function '@param cb as stdICallable<(element: Variant)=>Boolean> - condition to check. '@returns - `True` if none of the elements satisfy the condition, else `false`. Public Function checkNone(ByVal cb As stdICallable) As Boolean + Dim sourceIteration As TSourceIteration Dim oRet As Collection: Set oRet = New Collection Dim v As Variant: v = Null Dim i As Long: i = 0 Dim kv As Variant: kv = Null - While NextItem(kv, v, i) + While NextItem(kv, v, i, sourceIteration) If cb.Run(v, kv) Then checkNone = False Exit Function @@ -998,12 +1077,13 @@ End Function '@param cb as stdICallable<(element: Variant)=>Boolean> - condition to check. '@returns - `True` if only one of the elements satisfy the condition, else `false`. Public Function checkOnlyOne(ByVal cb As stdICallable) As Boolean + Dim sourceIteration As TSourceIteration Dim oRet As Collection: Set oRet = New Collection Dim v As Variant: v = Null Dim i As Long: i = 0 Dim kv As Variant: kv = Null Dim iMatched As Long: iMatched = 0 - While NextItem(kv, v, i) + While NextItem(kv, v, i, sourceIteration) If cb.Run(v, kv) Then iMatched = iMatched + 1 If iMatched > 1 Then @@ -1032,6 +1112,7 @@ End Function '@param vInitialValue - initial value to use in reducer. '@returns - The reduced value. Public Function reduce(ByVal cb As stdICallable, Optional ByVal initialValue As Variant = 0) As Variant + Dim sourceIteration As TSourceIteration Dim v As Variant: v = Null Dim i As Long: i = 0 Dim kv As Variant: kv = Null @@ -1039,29 +1120,31 @@ Public Function reduce(ByVal cb As stdICallable, Optional ByVal initialValue As 'Obtain initial value Dim vRet As Variant If IsMissing(initialValue) Then - Call NextItem(kv, v, i) + Call NextItem(kv, v, i, sourceIteration) Call CopyVariant(vRet, v) Else Call CopyVariant(vRet, initialValue) End If 'Reduce - While NextItem(kv, v, i) + While NextItem(kv, v, i, sourceIteration) Call CopyVariant(vRet, cb.Run(vRet, v, kv)) Wend Call CopyVariant(reduce, vRet) End Function 'Count the number of times a condition is met +'@remark Windows IEnumVARIANT sources are read one item at a time during counting, without retaining consumed values. '@param cb as stdICallable<(element: Variant)=>Boolean> - The callable to test for '@returns - The number of times a condition is met Public Function countBy(ByVal cb As stdICallable) As Long + Dim sourceIteration As TSourceIteration Dim iRet As Long: iRet = 0 Dim v As Variant: v = Null Dim i As Long: i = 0 Dim kv As Variant: kv = Null - While NextItem(kv, v, i) + While NextItem(kv, v, i, sourceIteration) If cb.Run(v, kv) Then iRet = iRet + 1 Wend countBy = iRet @@ -1071,6 +1154,7 @@ End Function '@param cb as stdICallable<(element: Variant)=>Boolean> - The callable which when run on an element returns the key to group by '@returns - Creates a dictionary, who's keys lead to `stdEnumerator`'s of elements matching that grouping. Public Function groupBy(ByVal cb As stdICallable) As Object + Dim sourceIteration As TSourceIteration Dim oRet As Object: Set oRet = CreateObject("Scripting.Dictionary") Dim key As Variant @@ -1078,7 +1162,7 @@ Public Function groupBy(ByVal cb As stdICallable) As Object Dim i As Long: i = 0 Dim kv As Variant: kv = Null - While NextItem(kv, v, i) + While NextItem(kv, v, i, sourceIteration) Call CopyVariant(key, cb.Run(v, kv)) If Not oRet.exists(key) Then Set oRet(key) = New Collection Call oRet(key).add(v) @@ -1112,13 +1196,14 @@ End Function '@param cb as stdICallable<(element: Variant)=>Variant> - If callback given, callback to obtain max value of. Else max of the elements values are found. '@returns - Value containing max data Public Function max(Optional ByVal cb As stdICallable = Nothing) As Variant + Dim sourceIteration As TSourceIteration Dim vRet As Variant Dim vMaxValue As Variant Dim v As Variant: v = Null Dim i As Long: i = 0 Dim kv As Variant: kv = Null - While NextItem(kv, v, i) + While NextItem(kv, v, i, sourceIteration) 'Get value to test Dim vtValue As Variant If cb Is Nothing Then @@ -1144,13 +1229,14 @@ End Function '@param cb as stdICallable<(element: Variant)=>Variant> - If callback given, callback to obtain min value of. Else min of the elements values are found. '@returns - Value containing min data Public Function min(Optional ByVal cb As stdICallable = Nothing) As Variant + Dim sourceIteration As TSourceIteration Dim vRet As Variant Dim vMaxValue As Variant Dim v As Variant: v = Null Dim i As Long: i = 0 Dim kv As Variant: kv = Null - While NextItem(kv, v, i) + While NextItem(kv, v, i, sourceIteration) 'Get value to test Dim vtValue As Variant If cb Is Nothing Then @@ -1176,13 +1262,14 @@ End Function '@param cb as stdICallable<(element: Variant)=>Variant> - A callback returning an item to sum. If no callback supplied then the elements themselves will be summed '@returns - The sum value Public Function sum(Optional ByVal cb As stdICallable = Nothing) As Variant + Dim sourceIteration As TSourceIteration Dim vRet As Variant Dim oRet As Collection: Set oRet = New Collection Dim v As Variant: v = Null Dim i As Long: i = 0 Dim kv As Variant: kv = Null - While NextItem(kv, v, i) + While NextItem(kv, v, i, sourceIteration) 'Get value to test Dim vtValue As Variant If cb Is Nothing Then @@ -1207,6 +1294,7 @@ End Function '@param cb as stdICallable<(element: variant, key: variant)=>Void> - Callback to execute on each item '@returns - This object. Public Function Cycle(ByVal iTimes As Long, ByVal cb As stdICallable) As stdEnumerator + Dim sourceIteration As TSourceIteration Dim i As Long For i = 1 To iTimes Dim oRet As Collection: Set oRet = New Collection @@ -1214,7 +1302,7 @@ Public Function Cycle(ByVal iTimes As Long, ByVal cb As stdICallable) As stdEnum Dim ti As Long: ti = 0 Dim kv As Variant: kv = Null - While NextItem(kv, v, ti) + While NextItem(kv, v, ti, sourceIteration) Call cb.Run(v, kv) Wend Next @@ -1226,12 +1314,13 @@ End Function '@param ifMissing - Value to return if missing '@returns - Value found or value of isMissing if missing Public Function FindFirst(ByVal cb As stdICallable, Optional ByVal ifMissing As Variant = Null) As Variant + Dim sourceIteration As TSourceIteration Dim oRet As Collection: Set oRet = New Collection Dim v As Variant: v = Null Dim i As Long: i = 0 Dim kv As Variant: kv = Null - While NextItem(kv, v, i) + While NextItem(kv, v, i, sourceIteration) If cb.Run(v, kv) Then Call CopyVariant(FindFirst, v) Exit Function @@ -1246,13 +1335,14 @@ End Function '@param byIndex - If true, then index of item is used, instead of potentially key. '@returns - Item returned at index/key Public Property Get Item(ByVal index As Variant, Optional ByVal byIndex As Boolean = False) As Variant + Dim sourceIteration As TSourceIteration Dim oRet As Collection: Set oRet = New Collection Dim v As Variant: v = Null Dim ti As Long: ti = 0 Dim kv As Variant: kv = Null 'Loop through items until - While NextItem(kv, v, ti) + While NextItem(kv, v, ti, sourceIteration) Dim bMeetsRequirements As Boolean If byIndex Then bMeetsRequirements = ti = index @@ -1270,8 +1360,10 @@ Public Property Get Item(ByVal index As Variant, Optional ByVal byIndex As Boole End Property 'Get the length of the enumerator +'@remark Windows IEnumVARIANT sources are fully traversed on every call without caching their values. '@returns - Length of enumerator. Public Property Get Length() As Long + Dim sourceIteration As TSourceIteration Dim oRet As Collection: Set oRet = New Collection Dim v As Variant: v = Null Dim i As Long: i = 0 @@ -1280,7 +1372,7 @@ Public Property Get Length() As Long Length = UBound(This.BaseArray) - LBound(This.BaseArray) + 1 Else Dim kv As Variant: kv = Null - While NextItem(kv, v, i) + While NextItem(kv, v, i, sourceIteration) If i > iMaxLength Then Err.Raise 1, "stdEnumerator", "Error: In order to prevent freezes stdEnumerator's Length() has a maxLength parameter. If the length of your collection is greater than this figure the program will error. Either Potential freeze detected." End If @@ -1291,18 +1383,20 @@ Public Property Get Length() As Long End Property 'Get the first `iCount` elements of the collection. Useful for testing -'@param iCount - The number of elements to retrieve +'@param iCount - The number of elements to retrieve. Non-positive counts return an empty enumerator. '@returns - Enumerator containing the first `iCount` elements. '@example ```vb 'stdEnumerator.CreateFromArray(Array(1,2,3,4,5,6,7,8,9)).First(3).Join(",") '=>1,2,3 '``` Public Function First(ByVal iCount As Long) As stdEnumerator + Dim sourceIteration As TSourceIteration Dim oRet As Collection: Set oRet = New Collection Dim v As Variant: v = Null Dim ti As Long: ti = 0 Dim kv As Variant: kv = Null - While NextItem(kv, v, ti) + If iCount <= 0 Then GoTo Jump + While NextItem(kv, v, ti, sourceIteration) oRet.Add v 'Reduce and check counter @@ -1391,10 +1485,46 @@ End Function '@param vNextKey - Previous/Next Key '@param vNextItem - Previous/Next Item '@param iCurrentIndex - Previous/Next Index +'@param sourceIteration - Cursor state owned by this traversal, independent of other operations. '@returns - Whether a new item was succesfully obtained or not. -'@TODO: Use DispCallFunc to call IEnumVariant::Next instead of converting to array -Private Function NextItem(ByRef vNextKey As Variant, ByRef vNextItem As Variant, ByRef iCurrentIndex As Long) As Boolean +Private Function NextItem(ByRef vNextKey As Variant, ByRef vNextItem As Variant, ByRef iCurrentIndex As Long, ByRef sourceIteration As TSourceIteration) As Boolean iCurrentIndex = iCurrentIndex + 1 + Select Case This.mode + Case FromIEnumVariant + #If Not Mac Then + With sourceIteration + If iCurrentIndex = 1 Then + Set .Cursor = Nothing + .Started = False + .Finished = False + .ErrorNumber = 0 + End If + If .ErrorNumber <> 0 Then Err.Raise .ErrorNumber, "stdEnumerator", "Source enumeration failed." + If .Finished Then Exit Function + If Not .Started Then + Set .Cursor = getNewEnum(This.EnumObject) + .Started = True + End If + Dim value As Variant, fetched As Long, hr As Long + hr = enumCall(ObjPtr(.Cursor), EIEnumVariant.evNext, 1&, VarPtr(value), VarPtr(fetched)) + If hr < 0 Then + .ErrorNumber = hr + Set .Cursor = Nothing + Err.Raise hr, "stdEnumerator", "IEnumVARIANT::Next failed." + End If + If hr = 1 Or fetched = 0 Then + .Finished = True + Set .Cursor = Nothing + End If + If fetched = 1 Then + CopyVariant vNextItem, value + vNextKey = iCurrentIndex + NextItem = True + End If + End With + Exit Function + #End If + End Select Select Case This.mode Case FromIEnumVariant, FromArray Dim lb As Long: lb = LBound(This.BaseArray, 1) @@ -1484,20 +1614,64 @@ Private Function CreateEmptyVBArray(Optional ByVal varType As VbVarType = vbVari End Function -'TODO: Implement raw API for working with IEnumVARIANT: -Private Function getNewEnum(ByRef o As Object) As IEnumVARIANT - -End Function -Private Function getNextVariant(ByRef o As IEnumVARIANT) As Variant - +#If Not Mac Then +'Invoke a COM vtable member. The caller must retain the interface reference. +#If VBA7 Then +Private Function enumCall(ByVal target As LongPtr, ByVal slot As Long, ParamArray args() As Variant) As Long +#Else +Private Function enumCall(ByVal target As Long, ByVal slot As Long, ParamArray args() As Variant) As Long +#End If + #If VBA7 Then + Dim pointers() As LongPtr + #Else + Dim pointers() As Long + #End If + Dim types() As Integer + Dim i As Long, result As Variant, hr As Long + ReDim pointers(0 To UBound(args)) + ReDim types(0 To UBound(args)) + For i = 0 To UBound(args) + types(i) = VarType(args(i)) + pointers(i) = VarPtr(args(i)) + Next + #If Win64 Then + hr = DispCallFunc(target, slot * 8, 4, vbLong, UBound(args) + 1, VarPtr(types(0)), VarPtr(pointers(0)), result) + #Else + hr = DispCallFunc(target, slot * 4, 4, vbLong, UBound(args) + 1, VarPtr(types(0)), VarPtr(pointers(0)), result) + #End If + If hr < 0 Then Err.Raise hr, "stdEnumerator", "COM invocation failed." + enumCall = result End Function -Private Sub skipNextVariant(ByRef o As IEnumVARIANT) - -End Sub -Private Function cloneEnumVariant(ByRef o As IEnumVARIANT) As IEnumVARIANT +'Obtain DISPID_NEWENUM and query its result for IEnumVARIANT. +Private Function getNewEnum(ByVal source As Object) As IUnknown + On Error GoTo Failed + Dim stage As String: stage = "Invoke _NewEnum" + Dim iid As TEnumGUID + #If VBA7 Then + Dim params(0 To 2) As LongPtr + Dim zero As LongPtr + #Else + Dim params(0 To 3) As Long + Dim zero As Long + #End If + Dim result As Variant, hr As Long + hr = enumCall(ObjPtr(source), EIDispatch.dispInvoke, -4&, VarPtr(iid), 0&, CInt(3), VarPtr(params(0)), VarPtr(result), zero, zero) + If hr < 0 Then Err.Raise hr, "stdEnumerator", "Cannot obtain _NewEnum." + stage = "Read _NewEnum result" + Dim unknown As IUnknown + Set unknown = result + stage = "Build IID" + iid.Data1 = &H20404 + iid.Data3 = &HC0 + iid.Data4 = &H46000000 + stage = "Query IEnumVARIANT" + Dim cursor As IUnknown + hr = enumCall(ObjPtr(unknown), EIUnknown.unkQueryInterface, VarPtr(iid), VarPtr(cursor)) + If hr < 0 Then Err.Raise hr, "stdEnumerator", "Source does not expose IEnumVARIANT." + Set getNewEnum = cursor + Exit Function +Failed: + Err.Raise Err.Number, "stdEnumerator::getNewEnum", stage & ": " & Err.Description End Function -Private Sub resetEnumVariant(ByRef o As IEnumVARIANT) - -End Sub - +#End If