diff --git a/core/filtering.go b/core/filtering.go index 8e5649e..18704f5 100644 --- a/core/filtering.go +++ b/core/filtering.go @@ -1,4 +1,119 @@ package core -type FilterDef struct { +import ( + "io/fs" + + "github.com/snivilised/traverse/enums" +) + +// TraverseFilter filter that can be applied to file system entries. When specified, +// the callback will only be invoked for file system nodes that pass the filter. +type ( + TraverseFilter interface { + // Description describes filter + Description() string + + // Validate ensures the filter definition is valid, panics when invalid + Validate() + + // Source, filter definition (comes from filter definition Pattern) + Source() string + + // IsMatch does this item match the filter + IsMatch(item *Node) bool + + // IsApplicable is this filter applicable to this item's scope + IsApplicable(item *Node) bool + + // Scope, what items this filter applies to + Scope() enums.FilterScope + } + + FilterDef struct { + // Type specifies the type of filter (mandatory) + Type enums.FilterType + + // Description describes filter (optional) + Description string + + // Pattern filter definition (mandatory) + Pattern string + + // Scope which file system entries this filter applies to (defaults + // to ScopeAllEn) + Scope enums.FilterScope + + // Negate, reverses the applicability of the filter (Defaults to false) + Negate bool + + // IfNotApplicable, when the filter does not apply to a directory entry, + // this value determines whether the callback is invoked for this entry + // or not (defaults to TriStateBoolTrueEn/true). + IfNotApplicable enums.TriStateBool + + // Custom client define-able filter. When restoring for resume feature, + // its the client's responsibility to restore this themselves (see + // PersistenceRestorer) + Custom TraverseFilter `json:"-"` + + // Poly allows for the definition of a PolyFilter which contains separate + // filters that target files and folders separately. If present, then + // all other fields are redundant, since the filter definitions inside + // Poly should be referred to instead. + Poly *PolyFilterDef + } + + PolyFilterDef struct { + File FilterDef + Folder FilterDef + } + + // ChildTraverseFilter filter that can be applied to a folder's collection of entries + // when subscription is + + ChildTraverseFilter interface { + // Description describes filter + Description() string + + // Validate ensures the filter definition is valid, panics when invalid + Validate() + + // Source, filter definition (comes from filter definition Pattern) + Source() string + + // Matching returns the collection of files contained within this + // item's folder that matches this filter. + Matching(children []fs.DirEntry) []fs.DirEntry + } + + ChildFilterDef struct { + // Type specifies the type of filter (mandatory) + Type enums.FilterType + + // Description describes filter (optional) + Description string + + // Pattern filter definition (mandatory) + Pattern string + + // Negate, reverses the applicability of the filter (Defaults to false) + Negate bool + + // Custom client define-able filter. When restoring for resume feature, + // its the client's responsibility to restore this themselves (see + // PersistenceRestorer) + Custom ChildTraverseFilter `json:"-"` + } + + compoundCounters struct { + filteredIn uint + filteredOut uint + } +) + +var BenignNodeFilterDef = FilterDef{ + Type: enums.FilterTypeRegex, + Description: "benign allow all", + Pattern: ".", + Scope: enums.ScopeRoot, } diff --git a/cycle/cycle-defs.go b/cycle/cycle-defs.go index 3ef317c..b10d2a7 100644 --- a/cycle/cycle-defs.go +++ b/cycle/cycle-defs.go @@ -22,8 +22,12 @@ type ( // be used by any notification with this signature. SimpleHandler func() + BeginState struct { + Root string + } + // BeginHandler invoked before traversal begins - BeginHandler func(root string) + BeginHandler func(state *BeginState) // EndHandler invoked at the end of traversal EndHandler func(result core.TraverseResult) diff --git a/cycle/event-begin_test.go b/cycle/event-begin_test.go index 109448e..cabb6d4 100644 --- a/cycle/event-begin_test.go +++ b/cycle/event-begin_test.go @@ -4,6 +4,7 @@ import ( . "github.com/onsi/ginkgo/v2" //nolint:revive // ok . "github.com/onsi/gomega" //nolint:revive // ok + "github.com/snivilised/traverse/cycle" "github.com/snivilised/traverse/pref" ) @@ -15,10 +16,12 @@ var _ = Describe("event", func() { invoked := false o, _ := pref.Get() - o.Events.Begin.On(func(_ string) { + o.Events.Begin.On(func(_ *cycle.BeginState) { invoked = true }) - o.Binder.Controls.Begin.Dispatch()(traversalRoot) + o.Binder.Controls.Begin.Dispatch()(&cycle.BeginState{ + Root: traversalRoot, + }) Expect(invoked).To(BeTrue()) }) @@ -29,16 +32,20 @@ var _ = Describe("event", func() { invoked := false o, _ := pref.Get() - o.Events.Begin.On(func(_ string) { + o.Events.Begin.On(func(_ *cycle.BeginState) { invoked = true }) o.Binder.Controls.Begin.Mute() - o.Binder.Controls.Begin.Dispatch()(traversalRoot) + o.Binder.Controls.Begin.Dispatch()(&cycle.BeginState{ + Root: traversalRoot, + }) Expect(invoked).To(BeFalse(), "notification not muted") invoked = false o.Binder.Controls.Begin.Unmute() - o.Binder.Controls.Begin.Dispatch()(traversalRoot) + o.Binder.Controls.Begin.Dispatch()(&cycle.BeginState{ + Root: traversalRoot, + }) Expect(invoked).To(BeTrue(), "notification not muted") }) }) @@ -50,21 +57,25 @@ var _ = Describe("event", func() { count := 0 o, _ := pref.Get() - o.Events.Begin.On(func(_ string) { + o.Events.Begin.On(func(_ *cycle.BeginState) { count++ }) - o.Events.Begin.On(func(_ string) { + o.Events.Begin.On(func(_ *cycle.BeginState) { count++ }) - o.Binder.Controls.Begin.Dispatch()(traversalRoot) + o.Binder.Controls.Begin.Dispatch()(&cycle.BeginState{ + Root: traversalRoot, + }) Expect(count).To(Equal(2), "not all listeners were invoked for first notification") count = 0 - o.Events.Begin.On(func(_ string) { + o.Events.Begin.On(func(_ *cycle.BeginState) { count++ }) - o.Binder.Controls.Begin.Dispatch()(anotherRoot) + o.Binder.Controls.Begin.Dispatch()(&cycle.BeginState{ + Root: anotherRoot, + }) Expect(count).To(Equal(3), "not all listeners were invoked for second notification") }) }) @@ -74,15 +85,17 @@ var _ = Describe("event", func() { count := 0 o, _ := pref.Get() - o.Events.Begin.On(func(_ string) { + o.Events.Begin.On(func(_ *cycle.BeginState) { count++ }) - o.Events.Begin.On(func(_ string) { + o.Events.Begin.On(func(_ *cycle.BeginState) { count++ }) o.Binder.Controls.Begin.Mute() - o.Binder.Controls.Begin.Dispatch()(anotherRoot) + o.Binder.Controls.Begin.Dispatch()(&cycle.BeginState{ + Root: anotherRoot, + }) Expect(count).To(Equal(0), "notification not muted") }) @@ -93,7 +106,9 @@ var _ = Describe("event", func() { It("๐Ÿงช should: invoke no-op", func() { o, _ := pref.Get() - o.Binder.Controls.Begin.Dispatch()(traversalRoot) + o.Binder.Controls.Begin.Dispatch()(&cycle.BeginState{ + Root: traversalRoot, + }) }) }) }) diff --git a/cycle/events.go b/cycle/events.go index ca3396d..faa78c8 100644 --- a/cycle/events.go +++ b/cycle/events.go @@ -140,14 +140,14 @@ func (c *NotificationCtrl[F]) Unmute() { } func broadcastBegin(listeners []BeginHandler) BeginHandler { - return func(root string) { + return func(state *BeginState) { for _, listener := range listeners { - listener(root) + listener(state) } } } -func nopBegin(_ string) {} +func nopBegin(*BeginState) {} func broadcastEnd(listeners []EndHandler) EndHandler { return func(result core.TraverseResult) { diff --git a/cycle/events_test.go b/cycle/events_test.go index 717a780..b951193 100644 --- a/cycle/events_test.go +++ b/cycle/events_test.go @@ -26,9 +26,9 @@ var _ = Describe("controls", func() { // client: // - events.Begin.On(func(root string) { + events.Begin.On(func(state *cycle.BeginState) { begun = true - Expect(root).To(Equal(path)) + Expect(state.Root).To(Equal(path)) }) events.End.On(func(_ core.TraverseResult) { @@ -37,7 +37,9 @@ var _ = Describe("controls", func() { // component side: // - controls.Begin.Dispatch()(path) + controls.Begin.Dispatch()(&cycle.BeginState{ + Root: path, + }) controls.End.Dispatch()(nil) Expect(begun).To(BeTrue(), "begin notification handler not invoked") diff --git a/director-prime_test.go b/director-prime_test.go index 01548b6..ef9dd4d 100644 --- a/director-prime_test.go +++ b/director-prime_test.go @@ -9,6 +9,7 @@ import ( . "github.com/onsi/gomega" //nolint:revive // ok tv "github.com/snivilised/traverse" "github.com/snivilised/traverse/core" + "github.com/snivilised/traverse/cycle" "github.com/snivilised/traverse/internal/services" "github.com/snivilised/traverse/pref" ) @@ -90,7 +91,7 @@ var _ = Describe("Director(Prime)", func() { Subscription: tv.SubscribeFiles, Handler: noOpHandler, }, - tv.WithOnBegin(func(_ string) {}), + tv.WithOnBegin(func(state *cycle.BeginState) {}), )).Navigate(ctx) wg.Wait() @@ -140,7 +141,7 @@ var _ = Describe("Director(Prime)", func() { Subscription: tv.SubscribeFiles, Handler: noOpHandler, }, - tv.WithFilter(&core.FilterDef{}), + tv.WithFilter(&pref.FilterOptions{}), tv.WithOnStart(func(_ string) {}), )).Navigate(ctx) diff --git a/director-resume_test.go b/director-resume_test.go index 508b57d..4d2b90f 100644 --- a/director-resume_test.go +++ b/director-resume_test.go @@ -10,6 +10,7 @@ import ( tv "github.com/snivilised/traverse" "github.com/snivilised/traverse/core" + "github.com/snivilised/traverse/cycle" "github.com/snivilised/traverse/internal/services" "github.com/snivilised/traverse/pref" ) @@ -19,7 +20,7 @@ var _ = Describe("Director(Resume)", Ordered, func() { BeforeAll(func() { restore = func(o *tv.Options) error { - o.Events.Begin.On(func(_ string) {}) + o.Events.Begin.On(func(state *cycle.BeginState) {}) return nil } @@ -105,7 +106,7 @@ var _ = Describe("Director(Resume)", Ordered, func() { From: RestorePath, Strategy: tv.ResumeStrategySpawn, }, - tv.WithFilter(&core.FilterDef{}), + tv.WithFilter(&pref.FilterOptions{}), restore, )).Navigate(ctx) diff --git a/internal/helpers/test-utilities.go b/internal/helpers/test-utilities.go index 2111a9f..44540f5 100644 --- a/internal/helpers/test-utilities.go +++ b/internal/helpers/test-utilities.go @@ -21,6 +21,12 @@ func Reason(name string) string { return fmt.Sprintf("โŒ for item named: '%v'", name) } +func BecauseQuantity(name string, expected, actual int) string { + return fmt.Sprintf("โŒ incorrect no of items for: '%v', expected: '%v', actual: '%v'", + name, expected, actual, + ) +} + func JoinCwd(segments ...string) string { if current, err := os.Getwd(); err == nil { parent, _ := filepath.Split(current) diff --git a/internal/kernel/base-plugin.go b/internal/kernel/base-plugin.go index 0960855..a2c6eb4 100644 --- a/internal/kernel/base-plugin.go +++ b/internal/kernel/base-plugin.go @@ -3,9 +3,11 @@ package kernel import ( "github.com/snivilised/traverse/enums" "github.com/snivilised/traverse/internal/types" + "github.com/snivilised/traverse/pref" ) type BasePlugin struct { + O *pref.Options Mediator types.Mediator Kontroller types.KernelController ActivatedRole enums.Role diff --git a/internal/kernel/gomega-matchers_test.go b/internal/kernel/gomega-matchers_test.go new file mode 100644 index 0000000..921149e --- /dev/null +++ b/internal/kernel/gomega-matchers_test.go @@ -0,0 +1,132 @@ +package kernel_test + +import ( + "fmt" + + . "github.com/onsi/gomega/types" //nolint:revive // ok + "github.com/snivilised/traverse/core" + "github.com/snivilised/traverse/internal/refine" +) + +// === MatchCurrentRegexFilter === +// + +type IsCurrentRegexMatchMatcher struct { + filter interface{} +} + +func MatchCurrentRegexFilter(expected interface{}) GomegaMatcher { + return &IsCurrentRegexMatchMatcher{ + filter: expected, + } +} + +func (m *IsCurrentRegexMatchMatcher) Match(actual interface{}) (bool, error) { + item, itemOk := actual.(*core.Node) + if !itemOk { + return false, fmt.Errorf("matcher expected a *TraverseItem (%T)", item) + } + + filter, filterOk := m.filter.(*refine.RegexFilter) + if !filterOk { + return false, fmt.Errorf("matcher expected a *RegexFilter (%T)", filter) + } + + return filter.IsMatch(item), nil +} + +func (m *IsCurrentRegexMatchMatcher) FailureMessage(actual interface{}) string { + item, _ := actual.(*core.Node) + filter, _ := m.filter.(*refine.RegexFilter) + + return fmt.Sprintf("๐Ÿ”ฅ Expected\n\t%v\nto match regex\n\t%v\n", item.Extension.Name, filter.Source()) +} + +func (m *IsCurrentRegexMatchMatcher) NegatedFailureMessage(actual interface{}) string { + item, _ := actual.(*core.Node) + filter, _ := m.filter.(*refine.RegexFilter) + + return fmt.Sprintf("๐Ÿ”ฅ Expected\n\t%v\nNOT to match regex\n\t%v\n", item.Extension.Name, filter.Source()) +} + +// === MatchCurrentGlobFilter === +// + +type IsCurrentGlobMatchMatcher struct { + filter interface{} +} + +func MatchCurrentGlobFilter(expected interface{}) GomegaMatcher { + return &IsCurrentGlobMatchMatcher{ + filter: expected, + } +} + +func (m *IsCurrentGlobMatchMatcher) Match(actual interface{}) (bool, error) { + item, itemOk := actual.(*core.Node) + if !itemOk { + return false, fmt.Errorf("matcher expected a *TraverseItem (%T)", item) + } + + filter, filterOk := m.filter.(*refine.GlobFilter) + if !filterOk { + return false, fmt.Errorf("matcher expected a *GlobFilter (%T)", filter) + } + + return filter.IsMatch(item), nil +} + +func (m *IsCurrentGlobMatchMatcher) FailureMessage(actual interface{}) string { + item, _ := actual.(*core.Node) + filter, _ := m.filter.(*refine.GlobFilter) + + return fmt.Sprintf("๐Ÿ”ฅ Expected\n\t%v\nto match glob\n\t%v\n", item.Extension.Name, filter.Source()) +} + +func (m *IsCurrentGlobMatchMatcher) NegatedFailureMessage(actual interface{}) string { + item, _ := actual.(*core.Node) + filter, _ := m.filter.(*refine.GlobFilter) + + return fmt.Sprintf("๐Ÿ”ฅ Expected\n\t%v\nNOT to match glob\n\t%v\n", item.Extension.Name, filter.Source()) +} + +// === MatchCurrentExtendedGlobFilter === +// + +type IsCurrentExtendedGlobMatchMatcher struct { + filter interface{} +} + +func MatchCurrentExtendedFilter(expected interface{}) GomegaMatcher { + return &IsCurrentExtendedGlobMatchMatcher{ + filter: expected, + } +} + +func (m *IsCurrentExtendedGlobMatchMatcher) Match(actual interface{}) (bool, error) { + item, itemOk := actual.(*core.Node) + if !itemOk { + return false, fmt.Errorf("matcher expected a *TraverseItem (%T)", item) + } + + filter, filterOk := m.filter.(*refine.ExtendedGlobFilter) + if !filterOk { + return false, fmt.Errorf("matcher expected a *IncaseFilter (%T)", filter) + } + + return filter.IsMatch(item), nil +} + +func (m *IsCurrentExtendedGlobMatchMatcher) FailureMessage(actual interface{}) string { + item, _ := actual.(*core.Node) + filter, _ := m.filter.(*refine.ExtendedGlobFilter) + + return fmt.Sprintf("๐Ÿ”ฅ Expected\n\t%v\nto match incase\n\t%v\n", item.Extension.Name, filter.Source()) +} + +func (m *IsCurrentExtendedGlobMatchMatcher) NegatedFailureMessage(actual interface{}) string { + item, _ := actual.(*core.Node) + filter, _ := m.filter.(*refine.ExtendedGlobFilter) + + return fmt.Sprintf("๐Ÿ”ฅ Expected\n\t%v\nNOT to match incase\n\t%v\n", item.Extension.Name, filter.Source()) +} diff --git a/internal/kernel/kernel-support_test.go b/internal/kernel/kernel-support_test.go index 8a03fc3..b982c4d 100644 --- a/internal/kernel/kernel-support_test.go +++ b/internal/kernel/kernel-support_test.go @@ -43,10 +43,27 @@ type naviTE struct { expectedNoOf quantities } +type filterTE struct { + naviTE + name string + pattern string + scope enums.FilterScope + negate bool + expectedErr error + errorContains string + ifNotApplicable enums.TriStateBool +} + +type polyTE struct { + naviTE + file core.FilterDef + folder core.FilterDef +} + func begin(em string) cycle.BeginHandler { - return func(root string) { + return func(state *cycle.BeginState) { GinkgoWriter.Printf( - "---> %v [traverse-navigator-test:BEGIN], root: '%v'\n", em, root, + "---> %v [traverse-navigator-test:BEGIN], root: '%v'\n", em, state.Root, ) } } diff --git a/internal/kernel/mediator.go b/internal/kernel/mediator.go index 7b51656..fbdb04a 100644 --- a/internal/kernel/mediator.go +++ b/internal/kernel/mediator.go @@ -2,26 +2,31 @@ package kernel import ( "context" + "io/fs" "github.com/snivilised/traverse/core" "github.com/snivilised/traverse/enums" "github.com/snivilised/traverse/internal/level" + "github.com/snivilised/traverse/internal/provision" "github.com/snivilised/traverse/internal/types" "github.com/snivilised/traverse/measure" "github.com/snivilised/traverse/pref" + "github.com/snivilised/traverse/tapable" ) // mediator controls traversal events, sends notifications and emits // life-cycle events type mediator struct { - root string - using *pref.Using - impl NavigatorImpl - guardian *guardian - frame *navigationFrame - pad *scratchPad // gets created just before nav begins - o *pref.Options - resources *types.Resources + root string + subscription enums.Subscription + using *pref.Using + impl NavigatorImpl + guardian *guardian + frame *navigationFrame + pad *scratchPad // gets created just before nav begins + o *pref.Options + resources *types.Resources + interceptors provision.Interceptors } func newMediator(using *pref.Using, @@ -31,9 +36,10 @@ func newMediator(using *pref.Using, resources *types.Resources, ) *mediator { return &mediator{ - root: using.Root, - using: using, - impl: impl, + root: using.Root, + subscription: using.Subscription, + using: using, + impl: impl, guardian: newGuardian(using.Handler, sealer, resources.Supervisor.Many( enums.MetricNoFilesInvoked, enums.MetricNoFoldersInvoked, @@ -44,6 +50,15 @@ func newMediator(using *pref.Using, pad: newScratch(o), o: o, resources: resources, + interceptors: provision.Interceptors{ + FilterChildren: tapable.NewHookCtrl[provision.FilterChildrenInterceptor]( + func([]fs.DirEntry) []fs.DirEntry { + // this is the default version, which does nothing. It is expected + // to be overridden by the filter plugin. + return []fs.DirEntry{} + }, + ), + }, } } @@ -105,3 +120,7 @@ func (m *mediator) Invoke(node *core.Node) error { func (m *mediator) Supervisor() *measure.Supervisor { return m.resources.Supervisor } + +func (m *mediator) Interceptors() *provision.Interceptors { + return &m.interceptors +} diff --git a/internal/kernel/navigation-controller.go b/internal/kernel/navigation-controller.go index ea083b8..85bb48d 100644 --- a/internal/kernel/navigation-controller.go +++ b/internal/kernel/navigation-controller.go @@ -8,7 +8,7 @@ import ( ) type NavigationController struct { - Mediator *mediator + Med *mediator } func (nc *NavigationController) Register(types.Plugin) error { @@ -16,20 +16,24 @@ func (nc *NavigationController) Register(types.Plugin) error { } func (nc *NavigationController) Ignite(ignition *types.Ignition) { - nc.Mediator.Ignite(ignition) + nc.Med.Ignite(ignition) } func (nc *NavigationController) Impl() NavigatorImpl { - return nc.Mediator.impl + return nc.Med.impl } func (nc *NavigationController) Navigate(ctx context.Context, ) (core.TraverseResult, error) { - return nc.Mediator.Navigate(ctx) + return nc.Med.Navigate(ctx) } func (nc *NavigationController) Result(ctx context.Context, err error, ) *types.KernelResult { - return nc.Mediator.impl.Result(ctx, err) + return nc.Med.impl.Result(ctx, err) +} + +func (nc *NavigationController) Mediator() types.Mediator { + return nc.Med } diff --git a/internal/kernel/navigator-factory.go b/internal/kernel/navigator-factory.go index b7cbcfa..9e9fa78 100644 --- a/internal/kernel/navigator-factory.go +++ b/internal/kernel/navigator-factory.go @@ -20,7 +20,7 @@ func New(using *pref.Using, o *pref.Options, return &Artefacts{ Kontroller: controller, - Mediator: controller.Mediator, + Mediator: controller.Med, Resources: resources, } } @@ -32,7 +32,7 @@ func newController(using *pref.Using, resources *types.Resources, ) *NavigationController { return &NavigationController{ - Mediator: newMediator(using, o, impl, sealer, resources), + Med: newMediator(using, o, impl, sealer, resources), } } diff --git a/internal/kernel/navigator-filter-glob_test.go b/internal/kernel/navigator-filter-glob_test.go new file mode 100644 index 0000000..c37e8b9 --- /dev/null +++ b/internal/kernel/navigator-filter-glob_test.go @@ -0,0 +1,235 @@ +package kernel_test + +import ( + "fmt" + "io/fs" + "path/filepath" + "testing/fstest" + + . "github.com/onsi/ginkgo/v2" //nolint:revive // ok + . "github.com/onsi/gomega" //nolint:revive // ok + + tv "github.com/snivilised/traverse" + "github.com/snivilised/traverse/core" + "github.com/snivilised/traverse/enums" + "github.com/snivilised/traverse/internal/helpers" + "github.com/snivilised/traverse/internal/lo" + "github.com/snivilised/traverse/internal/services" + "github.com/snivilised/traverse/pref" +) + +var _ = Describe("NavigatorFilterGlob", Ordered, func() { + var ( + vfs fstest.MapFS + root string + ) + + BeforeAll(func() { + const ( + verbose = true + ) + + vfs, root = helpers.Musico(verbose, + filepath.Join("MUSICO", "RETRO-WAVE"), + ) + Expect(root).NotTo(BeEmpty()) + }) + + BeforeEach(func() { + services.Reset() + }) + + DescribeTable("glob-filter", + func(ctx SpecContext, entry *filterTE) { + recording := make(recordingMap) + filterDefs := &pref.FilterOptions{ + Node: &core.FilterDef{ + Type: enums.FilterTypeGlob, + Description: entry.name, + Pattern: entry.pattern, + Scope: entry.scope, + Negate: entry.negate, + IfNotApplicable: entry.ifNotApplicable, + }, + } + var traverseFilter core.TraverseFilter + + path := helpers.Path(root, entry.relative) + + callback := func(item *core.Node) error { + indicator := lo.Ternary(item.IsFolder(), "๐Ÿ“", "๐Ÿ’ ") + GinkgoWriter.Printf( + "===> %v Glob Filter(%v) source: '%v', item-name: '%v', item-scope(fs): '%v(%v)'\n", + indicator, + traverseFilter.Description(), + traverseFilter.Source(), + item.Extension.Name, + item.Extension.Scope, + traverseFilter.Scope(), + ) + if lo.Contains(entry.mandatory, item.Extension.Name) { + Expect(item).Should(MatchCurrentGlobFilter(traverseFilter)) + } + + recording[item.Extension.Name] = len(item.Children) + return nil + } + result, err := tv.Walk().Configure().Extent(tv.Prime( + &tv.Using{ + Root: path, + Subscription: entry.subscription, + Handler: callback, + GetFS: func() fs.FS { + return vfs + }, + }, + tv.WithFilter(filterDefs), + tv.WithFilterReceiver(func(filter core.TraverseFilter, _ core.ChildTraverseFilter) { + traverseFilter = filter + }), + tv.WithHookQueryStatus(func(path string) (fs.FileInfo, error) { + return vfs.Stat(helpers.TrimRoot(path)) + }), + tv.WithHookReadDirectory(func(_ fs.FS, dirname string) ([]fs.DirEntry, error) { + return vfs.ReadDir(helpers.TrimRoot(dirname)) + }), + ), + ).Navigate(ctx) + + if entry.mandatory != nil { + for _, name := range entry.mandatory { + _, found := recording[name] + Expect(found).To(BeTrue(), helpers.Reason(name)) + } + } + + if entry.prohibited != nil { + for _, name := range entry.prohibited { + _, found := recording[name] + Expect(found).To(BeFalse(), helpers.Reason(name)) + } + } + + Expect(err).Error().To(BeNil()) + + Expect(result.Metrics().Count(enums.MetricNoFilesInvoked)).To(Equal(entry.expectedNoOf.files), + helpers.BecauseQuantity("Incorrect no of files", + int(entry.expectedNoOf.files), + int(result.Metrics().Count(enums.MetricNoFilesInvoked)), + ), + ) + + Expect(result.Metrics().Count(enums.MetricNoFoldersInvoked)).To(Equal(entry.expectedNoOf.folders), + helpers.BecauseQuantity("Incorrect no of folders", + int(entry.expectedNoOf.folders), + int(result.Metrics().Count(enums.MetricNoFoldersInvoked)), + ), + ) + + sum := lo.Sum(lo.Values(entry.expectedNoOf.children)) + + Expect(result.Metrics().Count(enums.MetricNoChildFilesFound)).To(Equal(uint(sum)), + helpers.BecauseQuantity("Incorrect total no of child files", + sum, + int(result.Metrics().Count(enums.MetricNoChildFilesFound)), + ), + ) + + }, + func(entry *filterTE) string { + return fmt.Sprintf("๐Ÿงช ===> given: '%v'", entry.message) + }, + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "universal(any scope): glob filter", + relative: "RETRO-WAVE", + subscription: enums.SubscribeUniversal, + expectedNoOf: quantities{ + files: 8, + folders: 0, + }, + }, + name: "items with '.flac' suffix", + pattern: "*.flac", + scope: enums.ScopeAll, + }), + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "universal(any scope): glob filter (negate)", + relative: "RETRO-WAVE", + subscription: enums.SubscribeUniversal, + expectedNoOf: quantities{ + files: 6, + folders: 8, + }, + }, + name: "items without .flac suffix", + pattern: "*.flac", + scope: enums.ScopeAll, + negate: true, + }), + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "universal(undefined scope): glob filter", + relative: "RETRO-WAVE", + subscription: enums.SubscribeUniversal, + expectedNoOf: quantities{ + files: 8, + folders: 0, + }, + }, + name: "items with '.flac' suffix", + pattern: "*.flac", + }), + + // === ifNotApplicable =============================================== + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "universal(any scope): glob filter (ifNotApplicable=true)", + relative: "RETRO-WAVE", + subscription: enums.SubscribeUniversal, + expectedNoOf: quantities{ + files: 8, + folders: 4, + }, + mandatory: []string{"A1 - Can You Kiss Me First.flac"}, + }, + name: "items with '.flac' suffix", + pattern: "*.flac", + scope: enums.ScopeLeaf, + ifNotApplicable: enums.TriStateBoolTrue, + }), + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "universal(leaf scope): glob filter (ifNotApplicable=false)", + relative: "RETRO-WAVE", + subscription: enums.SubscribeUniversal, + expectedNoOf: quantities{ + files: 8, + folders: 0, + }, + mandatory: []string{"A1 - Can You Kiss Me First.flac"}, + prohibited: []string{"vinyl-info.teenage-color"}, + }, + name: "items with '.flac' suffix", + pattern: "*.flac", + scope: enums.ScopeLeaf, + ifNotApplicable: enums.TriStateBoolFalse, + }), + ) + + DescribeTable("Filter Children (glob)", + func(ctx SpecContext, entry *filterTE) { + _, _ = ctx, entry + }, + + func(entry *filterTE) string { + return fmt.Sprintf("๐Ÿงช ===> given: '%v'", entry.message) + }, + ) +}) diff --git a/internal/kernel/navigator-folders.go b/internal/kernel/navigator-folders.go index 018be79..f22c349 100644 --- a/internal/kernel/navigator-folders.go +++ b/internal/kernel/navigator-folders.go @@ -8,8 +8,12 @@ import ( "github.com/snivilised/traverse/internal/types" ) +type childFiltering struct { + filter types.FilterChildren +} type navigatorFolders struct { navigatorAgent + children childFiltering } func (n *navigatorFolders) Top(ctx context.Context, @@ -59,8 +63,9 @@ func (n *navigatorFolders) inspect(ns *navigationStatic, ) // for the folders navigator, we ignore the user defined setting in - // n.o.Store.Behaviours.Sort.DirectoryEntryOrder, as we're only interested in - // folders and therefore force to use DirectoryEntryOrderFoldersFirstEn instead + // (Options).Core.Behaviours.Sort.DirectoryEntryOrder, as we're only + // interested in folders and therefore force to use + // enums.DirectoryEntryOrderFoldersFirst instead // vapour.cargo, err = read(ns.mediator.resources.FS.N, n.ro, @@ -69,9 +74,22 @@ func (n *navigatorFolders) inspect(ns *navigationStatic, vapour.sort(enums.EntryTypeFolder) - // TODO: implement directory with files + // this has to be inverted properly to prevent any filtering + // logic to appear here, rather, it being in the filter + // plugin. The current functionality here is too much ... + // eg, the metrics for filtering is not known here + // + if n.using.Subscription == enums.SubscribeFoldersWithFiles { + _ = ns.mediator.interceptors.FilterChildren.Invoke()( + vapour.contents().Files(), + ) + } extend(ns, vapour) return vapour, err } + +func (n *navigatorFolders) ProvideChildFilter(filter types.FilterChildren) { + n.children.filter = filter +} diff --git a/internal/kernel/navigator-hades.go b/internal/kernel/navigator-hades.go index d91e9dd..ddcda3e 100644 --- a/internal/kernel/navigator-hades.go +++ b/internal/kernel/navigator-hades.go @@ -30,3 +30,7 @@ func (n *navigatorHades) Navigate(ctx context.Context) (core.TraverseResult, err func (n *navigatorHades) Result(_ context.Context, err error) *types.KernelResult { return types.NewFailed(err) } + +func (n *navigatorHades) Mediator() types.Mediator { + return nil +} diff --git a/internal/lo/map.go b/internal/lo/map.go new file mode 100644 index 0000000..9c0ac48 --- /dev/null +++ b/internal/lo/map.go @@ -0,0 +1,224 @@ +package lo + +// Keys creates an array of the map keys. +// Play: https://go.dev/play/p/Uu11fHASqrU +func Keys[K comparable, V any](in map[K]V) []K { + result := make([]K, 0, len(in)) + + for k := range in { + result = append(result, k) + } + + return result +} + +// Values creates an array of the map values. +// Play: https://go.dev/play/p/nnRTQkzQfF6 +func Values[K comparable, V any](in map[K]V) []V { + result := make([]V, 0, len(in)) + + for _, v := range in { + result = append(result, v) + } + + return result +} + +// ValueOr returns the value of the given key or the fallback value if the key is not present. +// Play: https://go.dev/play/p/bAq9mHErB4V +func ValueOr[K comparable, V any](in map[K]V, key K, fallback V) V { + if v, ok := in[key]; ok { + return v + } + return fallback +} + +// PickBy returns same map type filtered by given predicate. +// Play: https://go.dev/play/p/kdg8GR_QMmf +func PickBy[K comparable, V any](in map[K]V, predicate func(key K, value V) bool) map[K]V { + r := map[K]V{} + for k, v := range in { + if predicate(k, v) { + r[k] = v + } + } + return r +} + +// PickByKeys returns same map type filtered by given keys. +// Play: https://go.dev/play/p/R1imbuci9qU +func PickByKeys[K comparable, V any](in map[K]V, keys []K) map[K]V { + r := map[K]V{} + for k, v := range in { + if Contains(keys, k) { + r[k] = v + } + } + return r +} + +// PickByValues returns same map type filtered by given values. +// Play: https://go.dev/play/p/1zdzSvbfsJc +func PickByValues[K comparable, V comparable](in map[K]V, values []V) map[K]V { + r := map[K]V{} + for k, v := range in { + if Contains(values, v) { + r[k] = v + } + } + return r +} + +// OmitBy returns same map type filtered by given predicate. +// Play: https://go.dev/play/p/EtBsR43bdsd +func OmitBy[K comparable, V any](in map[K]V, predicate func(key K, value V) bool) map[K]V { + r := map[K]V{} + for k, v := range in { + if !predicate(k, v) { + r[k] = v + } + } + return r +} + +// OmitByKeys returns same map type filtered by given keys. +// Play: https://go.dev/play/p/t1QjCrs-ysk +func OmitByKeys[K comparable, V any](in map[K]V, keys []K) map[K]V { + r := map[K]V{} + for k, v := range in { + if !Contains(keys, k) { + r[k] = v + } + } + return r +} + +// OmitByValues returns same map type filtered by given values. +// Play: https://go.dev/play/p/9UYZi-hrs8j +func OmitByValues[K comparable, V comparable](in map[K]V, values []V) map[K]V { + r := map[K]V{} + for k, v := range in { + if !Contains(values, v) { + r[k] = v + } + } + return r +} + +// Entries transforms a map into array of key/value pairs. +// Play: +func Entries[K comparable, V any](in map[K]V) []Entry[K, V] { + entries := make([]Entry[K, V], 0, len(in)) + + for k, v := range in { + entries = append(entries, Entry[K, V]{ + Key: k, + Value: v, + }) + } + + return entries +} + +// ToPairs transforms a map into array of key/value pairs. +// Alias of Entries(). +// Play: https://go.dev/play/p/3Dhgx46gawJ +func ToPairs[K comparable, V any](in map[K]V) []Entry[K, V] { + return Entries(in) +} + +// FromEntries transforms an array of key/value pairs into a map. +// Play: https://go.dev/play/p/oIr5KHFGCEN +func FromEntries[K comparable, V any](entries []Entry[K, V]) map[K]V { + out := make(map[K]V, len(entries)) + + for _, v := range entries { + out[v.Key] = v.Value + } + + return out +} + +// FromPairs transforms an array of key/value pairs into a map. +// Alias of FromEntries(). +// Play: https://go.dev/play/p/oIr5KHFGCEN +func FromPairs[K comparable, V any](entries []Entry[K, V]) map[K]V { + return FromEntries(entries) +} + +// Invert creates a map composed of the inverted keys and values. If map +// contains duplicate values, subsequent values overwrite property assignments +// of previous values. +// Play: https://go.dev/play/p/rFQ4rak6iA1 +func Invert[K comparable, V comparable](in map[K]V) map[V]K { + out := make(map[V]K, len(in)) + + for k, v := range in { + out[v] = k + } + + return out +} + +// Assign merges multiple maps from left to right. +// Play: https://go.dev/play/p/VhwfJOyxf5o +func Assign[K comparable, V any](maps ...map[K]V) map[K]V { + out := map[K]V{} + + for _, m := range maps { + for k, v := range m { + out[k] = v + } + } + + return out +} + +// MapKeys manipulates a map keys and transforms it to a map of another type. +// Play: https://go.dev/play/p/9_4WPIqOetJ +func MapKeys[K comparable, V any, R comparable](in map[K]V, iteratee func(value V, key K) R) map[R]V { + result := make(map[R]V, len(in)) + + for k, v := range in { + result[iteratee(v, k)] = v + } + + return result +} + +// MapValues manipulates a map values and transforms it to a map of another type. +// Play: https://go.dev/play/p/T_8xAfvcf0W +func MapValues[K comparable, V any, R any](in map[K]V, iteratee func(value V, key K) R) map[K]R { + result := make(map[K]R, len(in)) + + for k, v := range in { + result[k] = iteratee(v, k) + } + + return result +} + +// MapEntries manipulates a map entries and transforms it to a map of another type. +// Play: https://go.dev/play/p/VuvNQzxKimT +func MapEntries[K1 comparable, V1 any, K2 comparable, V2 any](in map[K1]V1, iteratee func(key K1, value V1) (K2, V2)) map[K2]V2 { + result := make(map[K2]V2, len(in)) + + for k1, v1 := range in { + k2, v2 := iteratee(k1, v1) + result[k2] = v2 + } + + return result +} + +// MapToSlice transforms a map into a slice based on specific iteratee +// Play: https://go.dev/play/p/ZuiCZpDt6LD +func MapToSlice[K comparable, V any, R any](in map[K]V, iteratee func(key K, value V) R) []R { + result := make([]R, 0, len(in)) + + for k, v := range in { + result = append(result, iteratee(k, v)) + } + + return result +} diff --git a/internal/lo/math.go b/internal/lo/math.go new file mode 100644 index 0000000..6f9647d --- /dev/null +++ b/internal/lo/math.go @@ -0,0 +1,86 @@ +package lo + +import ( + "golang.org/x/exp/constraints" +) + +// Range creates an array of numbers (positive and/or negative) with given length. +// Play: https://go.dev/play/p/0r6VimXAi9H +func Range(elementNum int) []int { + length := If(elementNum < 0, -elementNum).Else(elementNum) + result := make([]int, length) + step := If(elementNum < 0, -1).Else(1) + for i, j := 0, 0; i < length; i, j = i+1, j+step { + result[i] = j + } + return result +} + +// RangeFrom creates an array of numbers from start with specified length. +// Play: https://go.dev/play/p/0r6VimXAi9H +func RangeFrom[T constraints.Integer | constraints.Float](start T, elementNum int) []T { + length := If(elementNum < 0, -elementNum).Else(elementNum) + result := make([]T, length) + step := If(elementNum < 0, -1).Else(1) + for i, j := 0, start; i < length; i, j = i+1, j+T(step) { + result[i] = j + } + return result +} + +// RangeWithSteps creates an array of numbers (positive and/or negative) progressing from start up to, but not including end. +// step set to zero will return empty array. +// Play: https://go.dev/play/p/0r6VimXAi9H +func RangeWithSteps[T constraints.Integer | constraints.Float](start, end, step T) []T { + result := []T{} + if start == end || step == 0 { + return result + } + if start < end { + if step < 0 { + return result + } + for i := start; i < end; i += step { + result = append(result, i) + } + return result + } + if step > 0 { + return result + } + for i := start; i > end; i += step { + result = append(result, i) + } + return result +} + +// Clamp clamps number within the inclusive lower and upper bounds. +// Play: https://go.dev/play/p/RU4lJNC2hlI +func Clamp[T constraints.Ordered](value, min, max T) T { + if value < min { + return min + } else if value > max { + return max + } + return value +} + +// Sum sums the values in a collection. If collection is empty 0 is returned. +// Play: https://go.dev/play/p/upfeJVqs4Bt +func Sum[T constraints.Float | constraints.Integer | constraints.Complex](collection []T) T { + var sum T + for _, val := range collection { + sum += val + } + return sum +} + +// SumBy summarizes the values in a collection using the given return value from the iteration function. If collection is empty 0 is returned. +// Play: https://go.dev/play/p/Dz_a_7jN_ca +func SumBy[T any, R constraints.Float | constraints.Integer | constraints.Complex](collection []T, iteratee func(item T) R) R { + var sum R + for _, item := range collection { + sum += iteratee(item) + } + return sum +} diff --git a/internal/lo/types.go b/internal/lo/types.go new file mode 100644 index 0000000..9de8862 --- /dev/null +++ b/internal/lo/types.go @@ -0,0 +1,123 @@ +package lo + +// Entry defines a key/value pairs. +type Entry[K comparable, V any] struct { + Key K + Value V +} + +// Tuple2 is a group of 2 elements (pair). +type Tuple2[A any, B any] struct { + A A + B B +} + +// Unpack returns values contained in tuple. +func (t Tuple2[A, B]) Unpack() (A, B) { //nolint:gocritic // foo + return t.A, t.B +} + +// Tuple3 is a group of 3 elements. +type Tuple3[A any, B any, C any] struct { + A A + B B + C C +} + +// Unpack returns values contained in tuple. +func (t Tuple3[A, B, C]) Unpack() (A, B, C) { //nolint:gocritic // foo + return t.A, t.B, t.C +} + +// Tuple4 is a group of 4 elements. +type Tuple4[A any, B any, C any, D any] struct { + A A + B B + C C + D D +} + +// Unpack returns values contained in tuple. +func (t Tuple4[A, B, C, D]) Unpack() (A, B, C, D) { //nolint:gocritic // foo + return t.A, t.B, t.C, t.D +} + +// Tuple5 is a group of 5 elements. +type Tuple5[A any, B any, C any, D any, E any] struct { + A A + B B + C C + D D + E E +} + +// Unpack returns values contained in tuple. +func (t Tuple5[A, B, C, D, E]) Unpack() (A, B, C, D, E) { //nolint:gocritic // foo + return t.A, t.B, t.C, t.D, t.E +} + +// Tuple6 is a group of 6 elements. +type Tuple6[A any, B any, C any, D any, E any, F any] struct { + A A + B B + C C + D D + E E + F F +} + +// Unpack returns values contained in tuple. +func (t Tuple6[A, B, C, D, E, F]) Unpack() (A, B, C, D, E, F) { //nolint:gocritic // foo + return t.A, t.B, t.C, t.D, t.E, t.F +} + +// Tuple7 is a group of 7 elements. +type Tuple7[A any, B any, C any, D any, E any, F any, G any] struct { + A A + B B + C C + D D + E E + F F + G G +} + +// Unpack returns values contained in tuple. +func (t Tuple7[A, B, C, D, E, F, G]) Unpack() (A, B, C, D, E, F, G) { //nolint:gocritic // foo + return t.A, t.B, t.C, t.D, t.E, t.F, t.G +} + +// Tuple8 is a group of 8 elements. +type Tuple8[A any, B any, C any, D any, E any, F any, G any, H any] struct { + A A + B B + C C + D D + E E + F F + G G + H H +} + +// Unpack returns values contained in tuple. +func (t Tuple8[A, B, C, D, E, F, G, H]) Unpack() (A, B, C, D, E, F, G, H) { //nolint:gocritic // foo + return t.A, t.B, t.C, t.D, t.E, t.F, t.G, t.H +} + +// Tuple9 is a group of 9 elements. +type Tuple9[A any, B any, C any, D any, E any, F any, G any, H any, I any] struct { + A A + B B + C C + D D + E E + F F + G G + H H + I I +} + +// Unpack returns values contained in tuple. +func (t Tuple9[A, B, C, D, E, F, G, H, I]) Unpack() (A, B, C, D, E, F, G, H, I) { //nolint:gocritic // foo + return t.A, t.B, t.C, t.D, t.E, t.F, t.G, t.H, t.I +} diff --git a/internal/provision/interceptors.go b/internal/provision/interceptors.go new file mode 100644 index 0000000..8c6b1e4 --- /dev/null +++ b/internal/provision/interceptors.go @@ -0,0 +1,31 @@ +package provision + +import ( + "io/fs" + + "github.com/snivilised/traverse/tapable" +) + +// could we rename to plugins/plugs/plug-able + +// provision package provides a similar function to tapable except we +// use the name interceptor to replace hook. The difference between the +// two are that hooks allow for the client to customise core internal +// behaviour, where as an interceptor allows for internal behaviour to +// be customised by internal entities. One might wonder why this isn't +// implemented inside types as that package is for internal affairs only, +// but types does provide any functionality and types has dependencies +// that we should avoid in provision; that is to say we need to avoid +// circular dependencies;... + +type ( + FilterChildrenInterceptor func(files []fs.DirEntry) []fs.DirEntry + + Interceptors struct { + FilterChildren tapable.Hook[FilterChildrenInterceptor] + } +) + +func (i *Interceptors) UseFilterChildren(handler FilterChildrenInterceptor) { + i.FilterChildren = tapable.NewHookCtrl(handler) +} diff --git a/internal/refine/filter-base.go b/internal/refine/filter-base.go new file mode 100644 index 0000000..261e755 --- /dev/null +++ b/internal/refine/filter-base.go @@ -0,0 +1,69 @@ +package refine + +import ( + "github.com/snivilised/traverse/core" + "github.com/snivilised/traverse/enums" + "github.com/snivilised/traverse/internal/lo" +) + +// Filter ===================================================================== + +// Filter base filter struct. +type Filter struct { + name string + pattern string + scope enums.FilterScope // defines which file system nodes the filter should be applied to + negate bool // select to define a negative match + ifNotApplicable bool +} + +// Description description of the filter +func (f *Filter) Description() string { + return f.name +} + +// Source text defining the filter +func (f *Filter) Source() string { + return f.pattern +} + +func (f *Filter) IsApplicable(node *core.Node) bool { + return (f.scope & node.Extension.Scope) > 0 +} + +func (f *Filter) Scope() enums.FilterScope { + return f.scope +} + +func (f *Filter) invert(result bool) bool { + return lo.Ternary(f.negate, !result, result) +} + +func (f *Filter) Validate() { + if f.scope == enums.ScopeUndefined { + f.scope = enums.ScopeAll + } +} + +// ChildFilter ================================================================ + +// ChildFilter filter used when subscription is FoldersWithFiles +type ChildFilter struct { + Name string + Pattern string + Negate bool +} + +func (f *ChildFilter) Description() string { + return f.Name +} + +func (f *ChildFilter) Validate() {} + +func (f *ChildFilter) Source() string { + return f.Pattern +} + +func (f *ChildFilter) invert(result bool) bool { + return lo.Ternary(f.Negate, !result, result) +} diff --git a/internal/refine/filter-extended-glob.go b/internal/refine/filter-extended-glob.go new file mode 100644 index 0000000..5687f50 --- /dev/null +++ b/internal/refine/filter-extended-glob.go @@ -0,0 +1,93 @@ +package refine + +import ( + "io/fs" + "path/filepath" + "strings" + + "github.com/snivilised/traverse/core" + "github.com/snivilised/traverse/internal/lo" +) + +type ExtendedGlobFilter struct { + Filter + baseGlob string + suffixes []string + anyExtension bool + exclusion string +} + +func filterFileByExtendedGlob(name, base, exclusion string, + suffixes []string, anyExtension bool, +) bool { + extension := filepath.Ext(name) + baseName := strings.ToLower(strings.TrimSuffix(name, extension)) + + if baseMatch, _ := filepath.Match(base, baseName); !baseMatch { + return false + } + + if excluded, _ := filepath.Match(exclusion, baseName); excluded { + return false + } + + return lo.TernaryF(anyExtension, + func() bool { + return true + }, + func() bool { + return lo.TernaryF(extension == "", + func() bool { + return len(suffixes) == 0 + }, + func() bool { + return lo.Contains( + suffixes, strings.ToLower(strings.TrimPrefix(extension, ".")), + ) + }, + ) + }, + ) +} + +// IsMatch does this node match the filter +func (f *ExtendedGlobFilter) IsMatch(node *core.Node) bool { + if f.IsApplicable(node) { + result := lo.TernaryF(node.IsFolder(), + func() bool { + result, _ := filepath.Match(f.baseGlob, strings.ToLower(node.Extension.Name)) + + return result + }, + func() bool { + return filterFileByExtendedGlob( + node.Extension.Name, f.baseGlob, f.exclusion, f.suffixes, f.anyExtension, + ) + }, + ) + + return f.invert(result) + } + + return f.ifNotApplicable +} + +// ChildExtendedGlobFilter ========================================================== + +type ChildExtendedGlobFilter struct { + ChildFilter + baseGlob string + exclusion string + suffixes []string + anyExtension bool +} + +func (f *ChildExtendedGlobFilter) Matching(children []fs.DirEntry) []fs.DirEntry { + return lo.Filter(children, func(entry fs.DirEntry, _ int) bool { + name := entry.Name() + + return f.invert(filterFileByExtendedGlob( + name, f.baseGlob, f.exclusion, f.suffixes, f.anyExtension, + )) + }) +} diff --git a/internal/refine/filter-glob.go b/internal/refine/filter-glob.go new file mode 100644 index 0000000..2510813 --- /dev/null +++ b/internal/refine/filter-glob.go @@ -0,0 +1,39 @@ +package refine + +import ( + "io/fs" + "path/filepath" + + "github.com/snivilised/traverse/core" + "github.com/snivilised/traverse/internal/lo" +) + +// GlobFilter wildcard filter. +type GlobFilter struct { + Filter +} + +// IsMatch does this node match the filter +func (f *GlobFilter) IsMatch(node *core.Node) bool { + if f.IsApplicable(node) { + matched, _ := filepath.Match(f.pattern, node.Extension.Name) + return f.invert(matched) + } + + return f.ifNotApplicable +} + +// ChildGlobFilter ============================================================ + +type ChildGlobFilter struct { + ChildFilter +} + +// Matching returns the collection of files contained within this +// node's folder that matches this filter. +func (f *ChildGlobFilter) Matching(children []fs.DirEntry) []fs.DirEntry { + return lo.Filter(children, func(entry fs.DirEntry, _ int) bool { + matched, _ := filepath.Match(f.Pattern, entry.Name()) + return f.invert(matched) + }) +} diff --git a/internal/refine/filter-plugin.go b/internal/refine/filter-plugin.go index d73d0cf..76c8013 100644 --- a/internal/refine/filter-plugin.go +++ b/internal/refine/filter-plugin.go @@ -1,6 +1,8 @@ package refine import ( + "io/fs" + "github.com/snivilised/traverse/core" "github.com/snivilised/traverse/enums" "github.com/snivilised/traverse/internal/kernel" @@ -9,20 +11,25 @@ import ( ) func IfActive(o *pref.Options, mediator types.Mediator) types.Plugin { - if o.Core.Filter.Node != nil { + if o.Core.Filter.Node != nil || o.Core.Filter.Child != nil { return &Plugin{ BasePlugin: kernel.BasePlugin{ + O: o, Mediator: mediator, ActivatedRole: enums.RoleClientFilter, }, + receiver: o.Filtering.Receiver, } } return nil } +// Plugin manages all filtering aspects of navigation type Plugin struct { kernel.BasePlugin + filters NavigationFilters + receiver pref.FilterReceiver } func (p *Plugin) Name() string { @@ -32,24 +39,48 @@ func (p *Plugin) Name() string { func (p *Plugin) Register(kc types.KernelController) error { p.Kontroller = kc + if p.O.Core.Filter.Node != nil { + p.filters.Node = newNodeFilter(p.O.Core.Filter.Node) + } + + if p.O.Core.Filter.Child != nil { + if p.O.Core.Filter.Child.Pattern != "" || p.O.Core.Filter.Child.Custom != nil { + p.filters.Children = newChildFilter(p.O.Core.Filter.Child) + } + } + + // TODO: what about custom filters + + if p.receiver != nil { + p.receiver(p.filters.Node, p.filters.Children) + } + + kc.Mediator().Interceptors().UseFilterChildren( + func(files []fs.DirEntry) []fs.DirEntry { + // TODO: we need access to Mutable Metrics so we can call Tick + // + return files + }, + ) + return nil } func (p *Plugin) Next(node *core.Node) (bool, error) { - _ = node // if filtered in send filtered in message // if filtered out send filtered out message // these filtered in/out messages could be handled // by the metrics plugin, so that it can record // counts - - return true, nil + return p.filters.Node.IsMatch(node), nil } func (p *Plugin) Init() error { p.Mediator.Supervisor().Many( enums.MetricNoFoldersFilteredOut, enums.MetricNoFilesFilteredOut, + enums.MetricNoChildFilesFound, + enums.MetricNoChildFilesFilteredOut, ) return p.Mediator.Decorate(p) diff --git a/internal/refine/filter-poly.go b/internal/refine/filter-poly.go new file mode 100644 index 0000000..d5ee130 --- /dev/null +++ b/internal/refine/filter-poly.go @@ -0,0 +1,80 @@ +package refine + +import ( + "fmt" + + "github.com/snivilised/traverse/core" + "github.com/snivilised/traverse/enums" +) + +// PolyFilter is a dual filter that allows files and folders to be filtered +// independently. The Folder filter only applies when the current node +// is a file. This is because, filtering doesn't affect navigation, it only +// controls wether the client callback is invoked or not. That is to say, if +// a particular folder fails to pass a filter, the callback will not be +// invoked for that folder, but we still descend into it and navigate its +// children. This is the reason why the poly filter is only active when the +// the current node is a filter as the client callback will only be invoked +// for the file if its parent folder passes the poly folder filter and +// the file passes the poly file filter. +type PolyFilter struct { + // File is the filter that applies to a file. Note that the client does + // not have to set the File scope as this is enforced automatically as + // well as ensuring that the Folder scope has not been set. The client is + // still free to set other scopes. + File core.TraverseFilter + + // Folder is the filter that applies to a folder. Note that the client does + // not have to set the Folder scope as this is enforced automatically as + // well as ensuring that the File scope has not been set. The client is + // still free to set other scopes. + Folder core.TraverseFilter +} + +// Description +func (f *PolyFilter) Description() string { + return fmt.Sprintf("Poly - FILE: '%v', FOLDER: '%v'", + f.File.Description(), f.Folder.Description(), + ) +} + +// Validate ensures that both filters definition are valid, panics when invalid +func (f *PolyFilter) Validate() { + f.File.Validate() + f.Folder.Validate() +} + +// Source returns the Sources of both the File and Folder filters separated +// by a '##' +func (f *PolyFilter) Source() string { + return fmt.Sprintf("%v##%v", + f.File.Source(), f.Folder.Source(), + ) +} + +// IsMatch returns true if the current node is a file and both the current +// file matches the poly file filter and the file's parent folder matches +// the poly folder filter. Returns true of the current node is a folder. +func (f *PolyFilter) IsMatch(node *core.Node) bool { + if !node.IsFolder() { + return f.Folder.IsMatch(node.Parent) && f.File.IsMatch(node) + } + + return true +} + +// IsApplicable returns the result of applying IsApplicable to +// the poly Filter filter if the current node is a file, returns false +// for folders. +func (f *PolyFilter) IsApplicable(node *core.Node) bool { + if !node.IsFolder() { + return f.File.IsApplicable(node) + } + + return false +} + +// Scope is a bitwise OR combination of both filters +func (f *PolyFilter) Scope() enums.FilterScope { + return f.File.Scope() | f.Folder.Scope() +} diff --git a/internal/refine/filter-regex.go b/internal/refine/filter-regex.go new file mode 100644 index 0000000..0b73998 --- /dev/null +++ b/internal/refine/filter-regex.go @@ -0,0 +1,49 @@ +package refine + +import ( + "io/fs" + "regexp" + + "github.com/snivilised/traverse/core" + "github.com/snivilised/traverse/internal/lo" +) + +// RegexFilter ================================================================ + +// RegexFilter regex filter. +type RegexFilter struct { + Filter + rex *regexp.Regexp +} + +// Validate ensures the filter definition is valid, panics when invalid +func (f *RegexFilter) Validate() { + f.Filter.Validate() + f.rex = regexp.MustCompile(f.pattern) +} + +// IsMatch +func (f *RegexFilter) IsMatch(node *core.Node) bool { + if f.IsApplicable(node) { + return f.invert(f.rex.MatchString(node.Extension.Name)) + } + + return f.ifNotApplicable +} + +// ChildRegexFilter =========================================================== + +type ChildRegexFilter struct { + ChildFilter + rex *regexp.Regexp +} + +func (f *ChildRegexFilter) Validate() { + f.rex = regexp.MustCompile(f.Pattern) +} + +func (f *ChildRegexFilter) Matching(children []fs.DirEntry) []fs.DirEntry { + return lo.Filter(children, func(entry fs.DirEntry, _ int) bool { + return f.invert(f.rex.MatchString(entry.Name())) + }) +} diff --git a/internal/refine/new-filter.go b/internal/refine/new-filter.go new file mode 100644 index 0000000..daf6744 --- /dev/null +++ b/internal/refine/new-filter.go @@ -0,0 +1,220 @@ +package refine + +import ( + "errors" + "fmt" + "slices" + "strings" + + "github.com/snivilised/extendio/i18n" + "github.com/snivilised/extendio/xfs/utils" + "github.com/snivilised/traverse/core" + "github.com/snivilised/traverse/enums" + "github.com/snivilised/traverse/internal/lo" +) + +func fromExtendedGlobPattern(pattern string) (segments, suffixes []string, err error) { + if !strings.Contains(pattern, "|") { + return []string{}, []string{}, + errors.New("invalid extended glob filter definition; pattern is missing separator") + } + + segments = strings.Split(pattern, "|") + suffixes = strings.Split(segments[1], ",") + + suffixes = lo.Reject(suffixes, func(item string, _ int) bool { + return item == "" + }) + + return segments, suffixes, nil +} + +func newNodeFilter(def *core.FilterDef) core.TraverseFilter { + var ( + filter core.TraverseFilter + ifNotApplicable = true + err error + segments, suffixes []string + ) + + switch def.IfNotApplicable { + case enums.TriStateBoolTrue: + ifNotApplicable = true + + case enums.TriStateBoolFalse: + ifNotApplicable = false + + case enums.TriStateBoolUndefined: + } + + switch def.Type { + case enums.FilterTypeExtendedGlob: + if segments, suffixes, err = fromExtendedGlobPattern(def.Pattern); err != nil { + panic(err) + } + + base, exclusion := splitGlob(segments[0]) + + filter = &ExtendedGlobFilter{ + Filter: Filter{ + name: def.Description, + scope: def.Scope, + pattern: def.Pattern, + negate: def.Negate, + ifNotApplicable: ifNotApplicable, + }, + baseGlob: base, + suffixes: lo.Map(suffixes, func(s string, _ int) string { + return strings.ToLower(strings.TrimPrefix(strings.TrimSpace(s), ".")) + }), + anyExtension: slices.Contains(suffixes, "*"), + exclusion: exclusion, + } + + case enums.FilterTypeRegex: + filter = &RegexFilter{ + Filter: Filter{ + name: def.Description, + scope: def.Scope, + pattern: def.Pattern, + negate: def.Negate, + ifNotApplicable: ifNotApplicable, + }, + } + + case enums.FilterTypeGlob: + filter = &GlobFilter{ + Filter: Filter{ + name: def.Description, + scope: def.Scope, + pattern: def.Pattern, + negate: def.Negate, + ifNotApplicable: ifNotApplicable, + }, + } + + case enums.FilterTypeCustom: + if utils.IsNil(def.Custom) { + panic(i18n.NewMissingCustomFilterDefinitionError("Options/Store/FilterDefs/Node/Custom")) + } + + filter = def.Custom + + case enums.FilterTypePoly: + filter = newPolyFilter(def.Poly) + + case enums.FilterTypeUndefined: + panic(fmt.Sprintf("Filter definition for '%v' is missing the Type field", def.Description)) + } + + if def.Type != enums.FilterTypePoly { + filter.Validate() + } + + return filter +} + +func newPolyFilter(polyDef *core.PolyFilterDef) core.TraverseFilter { + // lets enforce the correct filter scopes + // + polyDef.File.Scope.Set(enums.ScopeFile) // file scope must be set for files + polyDef.File.Scope.Clear(enums.ScopeFolder) // folder scope must NOT be set for files + + polyDef.Folder.Scope.Set(enums.ScopeFolder) // folder scope must be set for folders + polyDef.Folder.Scope.Clear(enums.ScopeFile) // file scope must NOT be set for folders + + filter := &PolyFilter{ + File: newNodeFilter(&polyDef.File), + Folder: newNodeFilter(&polyDef.Folder), + } + + return filter +} + +const ( + exclusionDelim = "/" +) + +func splitGlob(baseGlob string) (base, exclusion string) { + base = strings.ToLower(baseGlob) + + if strings.Contains(base, exclusionDelim) { + constituents := strings.Split(base, exclusionDelim) + base = constituents[0] + exclusion = constituents[1] + } + + return base, exclusion +} + +func newChildFilter(def *core.ChildFilterDef) core.ChildTraverseFilter { + var ( + filter core.ChildTraverseFilter + ) + + if def == nil { + return nil + } + + switch def.Type { + case enums.FilterTypeExtendedGlob: + var ( + err error + segments, suffixes []string + ) + + if segments, suffixes, err = fromExtendedGlobPattern(def.Pattern); err != nil { + panic(errors.New("invalid incase filter definition; pattern is missing separator")) + } + + base, exclusion := splitGlob(segments[0]) + + filter = &ChildExtendedGlobFilter{ + ChildFilter: ChildFilter{ + Name: def.Description, + Pattern: def.Pattern, + Negate: def.Negate, + }, + baseGlob: base, + suffixes: lo.Map(suffixes, func(s string, _ int) string { + return strings.ToLower(strings.TrimPrefix(strings.TrimSpace(s), ".")) + }), + anyExtension: slices.Contains(suffixes, "*"), + exclusion: exclusion, + } + + case enums.FilterTypeRegex: + filter = &ChildRegexFilter{ + ChildFilter: ChildFilter{ + Name: def.Description, + Pattern: def.Pattern, + Negate: def.Negate, + }, + } + + case enums.FilterTypeGlob: + filter = &ChildGlobFilter{ + ChildFilter: ChildFilter{ + Name: def.Description, + Pattern: def.Pattern, + Negate: def.Negate, + }, + } + + case enums.FilterTypeCustom: + if utils.IsNil(def.Custom) { + panic(i18n.NewMissingCustomFilterDefinitionError( + "Options/Store/FilterDefs/Children/Custom", + )) + } + + filter = def.Custom + + case enums.FilterTypeUndefined: + case enums.FilterTypePoly: + } + + filter.Validate() + + return filter +} diff --git a/internal/refine/refine-defs.go b/internal/refine/refine-defs.go index b6eb105..593d771 100644 --- a/internal/refine/refine-defs.go +++ b/internal/refine/refine-defs.go @@ -1,3 +1,19 @@ package refine +import ( + "github.com/snivilised/traverse/core" +) + // refine defines filters and should be used by rx to alter observables + +type NavigationFilters struct { + // Node denotes the filter object that represents the Node file system item + // being visited. + // + Node core.TraverseFilter + + // Children denotes the Compound filter that is applied to the direct descendants + // of the current file system item being visited. + // + Children core.ChildTraverseFilter +} diff --git a/internal/resume/controller.go b/internal/resume/controller.go index 62259d0..1259159 100644 --- a/internal/resume/controller.go +++ b/internal/resume/controller.go @@ -26,6 +26,10 @@ func (c *Controller) Result(ctx context.Context, err error) *types.KernelResult return c.kc.Result(ctx, err) } +func (c *Controller) Mediator() types.Mediator { + return c.kc.Mediator() +} + func NewController(was *pref.Was, artefacts *kernel.Artefacts) *kernel.Artefacts { // The Controller on the incoming artefacts is the core navigator. It is // decorated here for resume. The strategy only needs access to the core navigator. diff --git a/internal/types/definitions.go b/internal/types/definitions.go index 1e31d6c..42b98a0 100644 --- a/internal/types/definitions.go +++ b/internal/types/definitions.go @@ -6,6 +6,7 @@ import ( "github.com/snivilised/traverse/core" "github.com/snivilised/traverse/enums" + "github.com/snivilised/traverse/internal/provision" "github.com/snivilised/traverse/measure" "github.com/snivilised/traverse/pref" ) @@ -52,6 +53,7 @@ type ( Navigate(ctx context.Context) (core.TraverseResult, error) Spawn(ctx context.Context, root string) (core.TraverseResult, error) Supervisor() *measure.Supervisor + Interceptors() *provision.Interceptors } FileSystems struct { @@ -82,6 +84,7 @@ type ( Facilities interface { Restoration Metrics() *measure.Supervisor + ProvideChildFilter(filter FilterChildren) } Ignition struct { @@ -93,6 +96,7 @@ type ( core.Navigator Ignite(ignition *Ignition) Result(ctx context.Context, err error) *KernelResult + Mediator() Mediator } ) @@ -137,3 +141,15 @@ func (r *KernelResult) Metrics() measure.Reporter { func (r *KernelResult) Error() error { return r.err } + +type ( + FilterChildren interface { + Matching(files []fs.DirEntry) []fs.DirEntry + } + + FilterChildrenFunc func(files []fs.DirEntry) []fs.DirEntry +) + +func (fn FilterChildrenFunc) Matching(files []fs.DirEntry) []fs.DirEntry { + return fn(files) +} diff --git a/pref/options-filter.go b/pref/options-filter.go index 0272d33..6f5a712 100644 --- a/pref/options-filter.go +++ b/pref/options-filter.go @@ -4,13 +4,42 @@ import ( "github.com/snivilised/traverse/core" ) -type FilterOptions struct { - Node *core.FilterDef +type ( + // FilterReceiver is represents the callback function a client + // can provide to enable them to receive the filter that has been + // created from the definition specified. + FilterReceiver func(filter core.TraverseFilter, child core.ChildTraverseFilter) + + FilteringOptions struct { + // Receiver allows client access to the filter that is derived from the + // filter definition + // + Receiver FilterReceiver + } + + FilterOptions struct { + // Node filter definitions that applies to the current file system node + // + Node *core.FilterDef + + // Child denotes the Child filter that is applied to the files which + // are direct descendants of the current directory node being visited. + // + Child *core.ChildFilterDef + } +) + +func WithFilter(filter *FilterOptions) Option { + return func(o *Options) error { + o.Core.Filter = *filter + + return nil + } } -func WithFilter(filter *core.FilterDef) Option { +func WithFilterReceiver(receiver FilterReceiver) Option { return func(o *Options) error { - o.Core.Filter.Node = filter + o.Filtering.Receiver = receiver return nil } diff --git a/pref/options.go b/pref/options.go index 337a1b0..869d100 100644 --- a/pref/options.go +++ b/pref/options.go @@ -45,6 +45,10 @@ type ( // Defects DefectOptions + // FilterDefined allows the client to receive the filter instance. + // + Filtering FilteringOptions + Binder *Binder } diff --git a/tapable/hooks.go b/tapable/hooks.go index ca39506..6c3158f 100644 --- a/tapable/hooks.go +++ b/tapable/hooks.go @@ -13,6 +13,17 @@ type ( Sort Hook[core.SortHook] } + /* + FilterChildren interface { + Matching(files []fs.DirEntry) []fs.DirEntry + } + + FilterChildrenFunc func(files []fs.DirEntry) []fs.DirEntry + */ + Internals struct { // need internal; under central layer + FilterChildren Hook[core.SubPathHook] + } + // HookCtrl contains the handler function to be invoked. The control // is agnostic to the handler's signature and therefore can not invoke it. HookCtrl[F any] struct { diff --git a/traverse-api.go b/traverse-api.go index 2527a7c..0d88459 100644 --- a/traverse-api.go +++ b/traverse-api.go @@ -20,7 +20,6 @@ type Director interface { } // director -// TODO: do we pass in another func to the director that represents the sync? type director func(bs *Builders) core.Navigator func (fn director) Extent(bs *Builders) core.Navigator { @@ -78,6 +77,7 @@ var ( WithDepth = pref.WithDepth WithFaultHandler = pref.WithFaultHandler WithFilter = pref.WithFilter + WithFilterReceiver = pref.WithFilterReceiver WithHibernationWake = pref.WithHibernationWake WithHibernationSleep = pref.WithHibernationSleep WithHibernationBehaviour = pref.WithHibernationBehaviour @@ -116,7 +116,7 @@ type Using = pref.Using // This high level list assumes everything can use core and enums; dependencies // can only point downwards. NB: These restrictions do not apply to the unit tests; // eg, "cycle_test" defines tests that are dependent on "pref", but "cycle" is prohibited -// from using "cycle". +// from using "pref". // ============================================================================ // ๐Ÿ”† user interface layer // traverse: [everything] @@ -130,6 +130,8 @@ type Using = pref.Using // // ๐Ÿ”† central layer // kernel: [] +// types: [measure, pref, provision] +// provision: [tapable], !("types") // --- // // ๐Ÿ”† support layer @@ -146,6 +148,7 @@ type Using = pref.Using // ๐Ÿ”† platform layer // core: [] // enums: [none] +// measure: [] // --- // ============================================================================ //