From 6482cea83876667bfb75c199cfcfdcfc8af35b7d Mon Sep 17 00:00:00 2001
From: plastikfan <plastikfan@outlook.com>
Date: Fri, 1 Apr 2022 14:28:55 +0100
Subject: [PATCH] feat(iterators): add prototype filtering functionality (#184)

---
 .../Classes/iterator-filters.class.ps1        | 426 ++++++++++++++++++
 .../Classes/iterator-filters.class.tests.ps1  |  67 +++
 Elizium.Loopz/Tests/Elizium.Loopz.tests.ps1   |  26 +-
 3 files changed, 506 insertions(+), 13 deletions(-)
 create mode 100644 Elizium.Loopz/Classes/iterator-filters.class.ps1
 create mode 100644 Elizium.Loopz/Tests/Classes/iterator-filters.class.tests.ps1

diff --git a/Elizium.Loopz/Classes/iterator-filters.class.ps1 b/Elizium.Loopz/Classes/iterator-filters.class.ps1
new file mode 100644
index 0000000..ba0a10a
--- /dev/null
+++ b/Elizium.Loopz/Classes/iterator-filters.class.ps1
@@ -0,0 +1,426 @@
+using namespace System;
+using namespace System.IO;
+using namespace System.Management.Automation;
+using namespace System.Collections;
+using namespace System.Collections.Generic;
+
+[Flags()]
+Enum FilterScope {
+  # filter applies to the current node's name (this would apply to files
+  # and directories)
+  #
+  Current = 1
+
+  # filter applies to the name of the parent of the current node
+  Parent = 2
+
+  # filter applies to node only if it is a leaf directory
+  Leaf = 4
+
+  # filter applies to node only if it is a child directory
+  Child = 8
+
+  # filter applies to node if it is a file
+  File = 16
+}
+
+class FilterOptions {
+  [char]$Not = '!';
+}
+
+class FilterSubject {
+  [PSCustomObject]$Data;
+  [int]$Segment;
+
+  FilterSubject([int]$segment) {
+    $this.Segment = $segment;
+  }
+
+  FilterSubject([PSCustomObject]$data) {
+    $this.Data = $data;
+  }
+}
+
+class CoreFilter {
+  [FilterOptions]$Options;
+  [FilterScope]$Scope = [FilterScope]::Current;
+
+  CoreFilter([FilterOptions]$options) {
+    $this.Options = $options;
+  }
+
+  [boolean]$_negate = $false;
+
+  [boolean] Pass([string]$value) {
+    throw [PSNotImplementedException]::new('CoreFilter.Pass');
+  }
+
+  [List[FileInfo]] FilesWhere([string]$value, [PSCustomObject]$info) {
+
+    [List[FileInfo]]$collection = $(
+      $info.DirectoryInfo.GetFiles()
+    );
+
+    return $collection;
+  } 
+} # CoreFilter
+
+class NoFilter : CoreFilter {
+  NoFilter([FilterOptions]$options): base($options) {
+
+  }
+
+  [string] ToString() {
+    return "(NoFilter)";
+  }
+
+  [boolean] Pass([string]$value) {
+    return $true;
+  }
+} # NoFilter
+
+class GlobFilter : CoreFilter {
+  [string]$Glob;
+
+  GlobFilter([string]$glob, [FilterOptions]$options): base($options) {
+    [string]$adjusted = if ($glob.StartsWith($options.Not)) {
+      $this._negate = $true;
+      $glob.Substring(1);
+    }
+    else {
+      $glob
+    }
+    $this.Glob = $adjusted;
+  }
+
+  [boolean] Pass([string]$value) {
+    [boolean]$result = $value -like $this.Glob;
+    return $this._negate ? -not($result) : $result;
+  }
+
+  [List[FileInfo]] FilesWhere([string]$value, [PSCustomObject]$info) {
+
+    [List[FileInfo]]$collection = $(
+      $info.DirectoryInfo.GetFiles()
+    );
+
+    return $collection;
+  }
+
+  [string] ToString() {
+    return "(glob: '$($this.Glob)')";
+  }
+} # GlobFilter
+
+class RegexFilter : CoreFilter {
+  [Regex]$Rexo;
+
+  RegexFilter([string]$expression, [string]$label, [FilterOptions]$options): base($options) {
+    [string]$adjusted = if ($expression.StartsWith($options.Not)) {
+      $this._negate = $true;
+      $expression.Substring(1);
+    }
+    else {
+      $expression;
+    }
+    $this.Rexo = New-RegularExpression -Expression $adjusted -Label $label;
+  }
+
+  [boolean] Pass([string]$value) {
+    [boolean]$result = $this.Rexo.IsMatch($value);
+    return $this._negate ? -not($result) : $result;
+  }
+
+  [List[FileInfo]] FilesWhere([string]$value, [PSCustomObject]$info) {
+
+    [List[FileInfo]]$collection = $(
+      $info.DirectoryInfo.GetFiles()
+    );
+
+    return $collection;
+  }
+
+  [string] ToString() {
+    return "(pattern: '$($this.Rexo)')";
+  }
+} # RegexFilter
+
+class FilterDriver {
+  [CoreFilter]$Core;
+
+  FilterDriver([CoreFilter]$core) {
+    $this.Core = $core;
+  }
+
+  [boolean] Accept([FilterSubject]$subject) {
+    throw [PSNotImplementedException]::new('FilterDriver.Accept');
+  }
+
+  [List[FileInfo]] FilesWhere([FilterSubject]$subject, [PSCustomObject]$info) {
+    return $this.Core.FilesWhere($subject.Value, $info);
+  }
+
+  # the strategy should create the subject, then invoke GetSubjectValue.
+  # but how does this work in the compound scenarios? The handler will
+  # call GetSubjectValue multiple times, one for each appropriate core
+  # filter, then pass this value to the core filter in pass/preview etc.
+  # The context creates the node via the strategy.
+  #
+  [string] GetSubjectValue([FilterSubject]$subject, [CoreFilter]$filter) {
+    # TODO: get this properly, using the FilterTarget on the core filter
+    #
+    # map $filter.Scope to an entry on the subject value
+    #
+    return $subject.Value;
+  }
+}
+
+class UnaryFilter: FilterDriver {
+  [CoreFilter]$Core
+  UnaryFilter([CoreFilter]$core) {
+    $this.Core = $core;
+  }
+
+  [boolean] Accept([FilterSubject]$subject) {
+    return $this.Core.Pass($subject.Value.$($this.Core.Scope));
+  }
+
+  [List[FileInfo]] FilesWhere([FilterSubject]$subject, [PSCustomObject]$info) {
+    return $this.Core.FilesWhere($subject.Value, $info);
+  }
+}
+
+# The CompoundType denotes what happen when there are multiple filters in force.
+# This is the intermediate type (CompoundFilter.All, CompoundFilter.Any)
+#
+Enum CompoundType {
+  # All filters must pass
+  All
+
+  # At least 1 filter must pass
+  Any
+}
+
+class CompoundHandler {
+  [hashtable]$Filters;
+
+  # not sure that we actually need the scope here,as its
+  # stored in the Filters hashtable
+  #
+  CompoundHandler([hashtable]$filters) {
+    $this.Filters = $filters;
+  }
+
+  [boolean] Accept([FilterSubject]$subject) {
+    throw [PSNotImplementedException]::new('CompoundHandler.Accept');
+  }
+
+  [List[FileInfo]] FilesWhere([FilterSubject]$subject, [PSCustomObject]$info) {
+    throw [PSNotImplementedException]::new('CompoundHandler.FilesWhere');
+  }
+
+  [string] GetFilterScope([FilterSubject]$subject, [CoreFilter]$filter) {
+    return $subject.Value.$($filter.Scope);
+  }
+}
+
+class AllCompoundHandler : CompoundHandler {
+  AllCompoundHandler([hashtable]$filters): base($filters) { }
+
+  [boolean] Accept([FilterSubject]$subject) {
+    [boolean]$accepted = $true;
+
+    [IEnumerator]$enumerator = $this.Filters.GetEnumerator();
+
+    while ($accepted -and $enumerator.MoveNext()) {
+      [CoreFilter]$filter = $enumerator.Current;
+
+      [string]$value = $this.GetFilterScope($subject, $filter);
+      $accepted = $filter.Pass($value);
+    }
+
+    return $accepted;
+  }
+
+  [List[FileInfo]] FilesWhere([FilterSubject]$subject, [PSCustomObject]$info) {
+    throw [PSNotImplementedException]::new('AllCompoundHandler.FilesWhere: AWAITING IMPL');
+  }
+}
+
+class AnyCompoundHandler : CompoundHandler {
+  AnyCompoundHandler([hashtable]$filters): base($filters) { }
+
+  [boolean] Accept([FilterSubject]$subject) {
+    [boolean]$accepted = $false;
+
+    [IEnumerator]$enumerator = $this.Filters.GetEnumerator();
+    while (-not($accepted) -and $enumerator.MoveNext()) {
+      [CoreFilter]$filter = $enumerator.Current;
+
+      [string]$value = $this.GetFilterScope($subject, $filter);
+      $accepted = $filter.Pass($value);
+    }
+
+    return $accepted;
+  }
+
+  [List[FileInfo]] FilesWhere([FilterSubject]$subject, [PSCustomObject]$info) {
+    throw [PSNotImplementedException]::new('AnyCompoundHandler.FilesWhere: AWAITING IMPL');
+  }
+}
+
+class CompoundFilter: FilterDriver {
+
+  # Checks the subject.Scope and acts accordingly
+  #
+  # FilterScope => filter
+  #
+  [hashtable]$Filters = @{}
+  [CompoundHandler]$Handler;
+  static [hashtable]$CompoundTypeToClassName = @{
+    [CompoundType]::All = "AllCompoundHandler";
+    [CompoundType]::Any = "AnyCompoundHandler"
+  };
+
+  CompoundFilter([CompoundType]$compoundType, [hashtable]$filters) {
+    $this.Filters = $filters;
+
+    if (-not([CompoundFilter]::CompoundTypeToClassName.ContainsKey($compoundType))) {
+      throw "CompoundFilter.ctor, invalid compound type: '$($compoundType)'";
+    }
+    $this.Handler = New-Object $([CompoundFilter]::CompoundTypeToClassName[$compoundType]) @($filters);
+  }
+
+  [boolean] Accept([FilterSubject]$subject) {
+    return $this.Handler.Accept($subject);
+  }
+
+  [List[FileInfo]] FilesWhere([FilterSubject]$subject, [PSCustomObject]$info) {
+    return $this.Handler.Accept($subject, $info);
+  }
+}
+
+class FilterNode {
+  [PSCustomObject]$Data;
+
+  FilterNode([PSCustomObject]$source) {
+    $this.Data = $source;
+  }
+
+  [string] ToString() {
+    return "FilterNode?"
+  }
+}
+
+
+class FilterStrategy {
+  [int]$ChildSegmentNo = 0;
+
+  FilterStrategy([PSCustomObject]$strategyInfo) {
+    $this.ChildSegmentNo = $strategyInfo.ChildSegmentNo;
+  }
+
+  [FilterNode] GetNode([PSCustomObject]$info) {
+    throw [NotImplementedException]::new("FilterStrategy.GetNode");
+  }
+
+  [boolean] Preview([FilterNode]$node) {
+    throw [PSNotImplementedException]::new("FilterStrategy.Preview");
+  }
+
+  static [array] GetSegments ([string]$rootPath, [string]$fullName) {
+    [string]$rootParentPath = Split-Path -Path $rootPath;
+    [string]$relativePath = $fullName.Substring($rootParentPath.Length + 1);
+
+    [array]$segments = $($global:IsWindows) ? $($relativePath -split "\\") : $(
+      $($relativePath -split [Path]::AltDirectorySeparatorChar)
+    );
+
+    return $segments;
+  } 
+}
+
+class LeafGenerationStrategy: FilterStrategy {
+  
+  LeafGenerationStrategy(): base([PSCustomObject]@{
+      ChildSegmentNo = 2;
+    }) {
+
+  }
+
+  [FilterNode] GetNode([PSCustomObject]$info) {
+    [boolean]$isLeaf = $($info.DirectoryInfo.GetDirectories()).Count -eq 0;
+    [string]$rootPath = $info.Exchange["LOOPZ.FILTER.ROOT-PATH"];
+    [string[]]$segments = [FilterStrategy]::GetSegments($rootPath, $info.DirectoryInfo.FullName);
+
+    [boolean]$childAvailable = $($segments.Length -gt $this.ChildSegmentNo);
+    [boolean]$leafAvailable = $($segments.Length -gt ($this.ChildSegmentNo + 1));
+
+    [string]$childName = $($childAvailable ?
+      $segments[$this.ChildSegmentNo] : [string]::Empty
+    );
+
+    [string]$leafName = $($($leafAvailable -and $isLeaf) ?
+      $segments[-1] : [string]::Empty
+    );
+
+    # segment 0 means something, not quite sure yet
+    #
+    [FilterSubject]$subject = [FilterSubject]::new([PSCustomObject]@{
+        ChildSegmentNo = $this.ChildSegmentNo;
+        IsChild        = $childName -eq $info.DirectoryInfo.Name;
+        IsLeaf         = $isLeaf;
+        SegmentNo      = 1;
+        Segments       = $segments;
+        FilterScope    = [PSCustomObject]@{
+          Current = $info.DirectoryInfo.Name;
+          Parent  = $info.DirectoryInfo.Parent.Name
+          Child   = $childName;
+          Leaf    = $leafName;
+        }
+      });
+
+    [FilterNode]$node = [FilterNode]::new([PSCustomObject]@{
+        DirectoryInfo = $info.DirectoryInfo;
+        Subject       = $subject;
+      });
+
+    return $node;
+  }
+
+  [boolean] Preview([FilterNode]$node) {
+    # -not($node.Data.Subject.IsChild may not be valid in this context
+    # because child was only relevant in the yank scenario.
+    #
+    return $(-not($node.Data.Subject.IsChild) -or 
+      $node.Data.Filter.Preview($node.Data.Subject));
+
+    # throw [PSNotImplementedException]::new(
+    #   'FilterStrategy.Preview'
+    # );
+  }
+}
+
+# Kerberus is the overall filter controller
+# since FilterScope is a bit based enum, we don't have to store it on each
+# individual core filter, it can be stored either on Kerberus, or the strategy
+#
+class Kerberus {
+  [FilterDriver]$Driver;
+
+  Kerberus([FilterDriver]$driver) {
+    $this.Driver = $driver;
+  }
+
+  [boolean] Preview([FilterSubject]$subject) {
+    return $this.Driver($subject);
+  }
+
+  [boolean] Pass([FilterSubject]$subject) {
+    return $this.Driver.Pass($subject);
+  }
+
+  [List[FileInfo]] FilesWhere([FilterSubject]$subject, [PSCustomObject]$info) {
+    return $this.Driver($info);
+  } 
+}
diff --git a/Elizium.Loopz/Tests/Classes/iterator-filters.class.tests.ps1 b/Elizium.Loopz/Tests/Classes/iterator-filters.class.tests.ps1
new file mode 100644
index 0000000..d8ac264
--- /dev/null
+++ b/Elizium.Loopz/Tests/Classes/iterator-filters.class.tests.ps1
@@ -0,0 +1,67 @@
+Describe 'Iterator Filters' -Tag "Current" {
+  BeforeAll {
+    Get-Module Elizium.Loopz | Remove-Module -Force;
+    Import-Module .\Output\Elizium.Loopz\Elizium.Loopz.psm1 `
+      -ErrorAction 'stop' -DisableNameChecking -Force;
+
+    Enum TargetEnum {
+      Current = 1
+      Parent = 2
+      Leaf = 4
+      Child = 8
+      File = 16
+    }
+  }
+
+  Context "given: statics" {
+    It "should: init ok" {
+      InModuleScope Elizium.Loopz {
+        [int]$length = ([CompoundFilter]::CompoundTypeToClassName).PSBase.Count;
+        Write-Host ">>> found '$($length)' members"
+
+        ([CompoundFilter]::CompoundTypeToClassName).PSBase.Keys | ForEach-Object {
+          [CompoundType]$compoundType = $_;
+          [string]$compoundTypeClass = ([CompoundFilter]::CompoundTypeToClassName)[$compoundType];
+          Write-Host "---> compound type: '$($compoundType)', class: '$($compoundTypeClass)'";
+
+          [hashtable]$filters = @{}
+          $handler = New-Object $($compoundTypeClass) @($filters);
+
+          $handler | Should -Not -BeNullOrEmpty;
+        }
+      }
+    }
+  }
+
+  Context "given: enum" {
+    It "should: convert to string" {
+      [TargetEnum]$parent = [TargetEnum]::Parent;
+      $parent | Should -BeExactly "Parent";
+    }
+  }
+
+  Context "given: object with enum field" {
+    It "should: convert to string" {
+      [PSCustomObject]$filter = @{
+        Target = [TargetEnum]::Current;
+      }
+
+      [PSCustomObject]$subject = [PSCustomObject]@{
+        SegmentNo      = 1;
+        ChildSegmentNo = 2;
+        IsChild        = $false;
+        IsLeaf         = $true;
+        Segments       = @("a", "b", "c");
+        Scope          = [PSCustomObject]@{
+          Current = "CURRENT-NODE"
+          Parent  = "PARENT-NODE"
+          Child   = "CHILD-NODE";
+          Leaf    = "LEAF-NODE";
+        }
+      }
+
+      [string]$target = $subject.Scope.$($filter.Target);
+      $target | Should -BeExactly "CURRENT-NODE";
+    }
+  }
+}
diff --git a/Elizium.Loopz/Tests/Elizium.Loopz.tests.ps1 b/Elizium.Loopz/Tests/Elizium.Loopz.tests.ps1
index dfb5ac5..504e5b5 100644
--- a/Elizium.Loopz/Tests/Elizium.Loopz.tests.ps1
+++ b/Elizium.Loopz/Tests/Elizium.Loopz.tests.ps1
@@ -1,20 +1,20 @@
 $moduleRoot = Resolve-Path "$PSScriptRoot/.."
 $moduleName = Split-Path $moduleRoot -Leaf
 
-Describe "General project validation: $moduleName" {
+Describe "General project validation: $moduleName" -Tag "Source" {
 
-    $scripts = Get-ChildItem $moduleRoot -Include *.ps1, *.psm1, *.psd1 -Recurse
+  $scripts = Get-ChildItem $moduleRoot -Include *.ps1, *.psm1, *.psd1 -Recurse
 
-    # TestCases are splatted to the script so we need hashtables
-    $testCase = $scripts | Foreach-Object {@{file = $_}}
-    It "Script <file> should be valid powershell" -TestCases $testCase {
-        param($file)
+  # TestCases are splatted to the script so we need hashtables
+  $testCase = $scripts | Foreach-Object { @{file = $_ } }
+  It "Script <file> should be valid powershell" -TestCases $testCase -Tag "Source" {
+    param($file)
 
-        $file.fullname | Should -Exist
+    $file.fullName | Should -Exist
 
-        $contents = Get-Content -Path (Resolve-Path $file.fullname) -ErrorAction Stop
-        $errors = $null
-        $null = [System.Management.Automation.PSParser]::Tokenize($contents, [ref]$errors)
-        $errors.Count | Should -Be 0
-    }
-}
\ No newline at end of file
+    $contents = Get-Content -Path (Resolve-Path $file.fullName) -ErrorAction Stop
+    $errors = $null
+    $null = [System.Management.Automation.PSParser]::Tokenize($contents, [ref]$errors)
+    $errors.Count | Should -Be 0
+  }
+}