From de867476f4f74dd377660590f2acd9b4b6a32f33 Mon Sep 17 00:00:00 2001 From: plastikfan Date: Thu, 11 Jul 2024 12:48:21 +0100 Subject: [PATCH] feat: implement filtering (#74) --- .vscode/settings.json | 3 + builders.go | 29 +- collections/positional-set.go | 4 +- core/directory-contents.go | 24 +- core/filtering.go | 117 ++++- cycle/cycle-defs.go | 6 +- cycle/event-begin_test.go | 43 +- cycle/events.go | 6 +- cycle/events_test.go | 8 +- director-prime_test.go | 5 +- director-resume_test.go | 5 +- go.mod | 2 +- internal/helpers/test-utilities.go | 10 + internal/hiber/hibernate-plugin.go | 2 +- internal/kernel/base-plugin.go | 2 + internal/kernel/extend.go | 4 +- internal/kernel/gomega-matchers_test.go | 132 +++++ internal/kernel/guardian.go | 4 +- internal/kernel/kernel-defs.go | 30 +- internal/kernel/kernel-suite_test.go | 136 +++++ internal/kernel/kernel-support_test.go | 122 ----- internal/kernel/mediator.go | 37 +- internal/kernel/navigation-controller.go | 14 +- internal/kernel/navigator-agent.go | 4 +- internal/kernel/navigator-factory.go | 17 +- internal/kernel/navigator-files.go | 9 +- .../navigator-filter-extended-glob_test.go | 490 ++++++++++++++++++ internal/kernel/navigator-filter-glob_test.go | 227 ++++++++ .../kernel/navigator-filter-regex_test.go | 265 ++++++++++ ...igator-folders-with-files-filtered_test.go | 180 +++++++ .../navigator-folders-with-files_test.go | 164 +++++- internal/kernel/navigator-folders.go | 16 +- internal/kernel/navigator-hades.go | 4 + internal/kernel/navigator-simple_test.go | 2 +- internal/kernel/navigator-universal.go | 5 +- internal/kernel/scratch-pad.go | 19 - internal/lo/find.go | 372 +++++++++++++ internal/lo/map.go | 224 ++++++++ internal/lo/math.go | 86 +++ internal/lo/type-manipulation.go | 108 ++++ internal/lo/types.go | 123 +++++ internal/override/actions.go | 57 ++ internal/refine/filter-base.go | 69 +++ internal/refine/filter-extended-glob.go | 93 ++++ internal/refine/filter-glob.go | 39 ++ internal/refine/filter-plugin.go | 69 ++- internal/refine/filter-poly.go | 80 +++ internal/refine/filter-regex.go | 49 ++ internal/refine/new-filter.go | 220 ++++++++ internal/refine/refine-defs.go | 16 + internal/resume/controller.go | 4 + internal/resume/resume-plugin.go | 2 +- internal/sampling/sampling-plugin.go | 2 +- internal/types/definitions.go | 21 +- measure/measure-defs.go | 11 +- measure/supervisor.go | 12 +- pref/options-filter.go | 37 +- pref/options.go | 4 + support_test.go | 22 - tapable/tapable-defs.go | 9 +- traverse-api.go | 7 +- traverse-suite_test.go | 17 + 62 files changed, 3591 insertions(+), 309 deletions(-) create mode 100644 internal/kernel/gomega-matchers_test.go delete mode 100644 internal/kernel/kernel-support_test.go create mode 100644 internal/kernel/navigator-filter-extended-glob_test.go create mode 100644 internal/kernel/navigator-filter-glob_test.go create mode 100644 internal/kernel/navigator-filter-regex_test.go create mode 100644 internal/kernel/navigator-folders-with-files-filtered_test.go delete mode 100644 internal/kernel/scratch-pad.go create mode 100644 internal/lo/find.go create mode 100644 internal/lo/map.go create mode 100644 internal/lo/math.go create mode 100644 internal/lo/type-manipulation.go create mode 100644 internal/lo/types.go create mode 100644 internal/override/actions.go create mode 100644 internal/refine/filter-base.go create mode 100644 internal/refine/filter-extended-glob.go create mode 100644 internal/refine/filter-glob.go create mode 100644 internal/refine/filter-poly.go create mode 100644 internal/refine/filter-regex.go create mode 100644 internal/refine/new-filter.go delete mode 100644 support_test.go diff --git a/.vscode/settings.json b/.vscode/settings.json index d92611b..dc052f5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -31,6 +31,7 @@ "fieldalignment", "fortytw", "fsys", + "Fugazi", "goconst", "gocritic", "gocyclo", @@ -48,12 +49,14 @@ "hiber", "icase", "ineffassign", + "Innerworld", "jibberjabber", "Kontroller", "leaktest", "linecomment", "linters", "lorax", + "Marillion", "mattn", "musico", "Mutables", diff --git a/builders.go b/builders.go index 5fb1705..15bceb4 100644 --- a/builders.go +++ b/builders.go @@ -1,9 +1,11 @@ package tv import ( + "github.com/snivilised/traverse/core" "github.com/snivilised/traverse/enums" "github.com/snivilised/traverse/internal/kernel" "github.com/snivilised/traverse/internal/lo" + "github.com/snivilised/traverse/internal/override" "github.com/snivilised/traverse/internal/types" "github.com/snivilised/traverse/measure" "github.com/snivilised/traverse/pref" @@ -42,13 +44,26 @@ func (bs *Builders) buildAll() (*buildArtefacts, error) { // BUILD NAVIGATOR // + actions := &override.Actions{ + HandleChildren: override.NewActionCtrl[override.HandleChildrenInterceptor]( + func(inspection core.Inspection, mums measure.MutableMetrics) { + // [KEEP-FILTER-IN-SYNC] keep this in sync with filter plugin.Init + files := inspection.Sort(enums.EntryTypeFile) + inspection.AssignChildren(files) + mums[enums.MetricNoChildFilesFound].Times(uint(len(files))) + }, + ), + } + artefacts, navErr := bs.navigator.Build(o, &types.Resources{ FS: types.FileSystems{ N: ext.navFS(), R: ext.resFS(), }, Supervisor: measure.New(), + Actions: actions, }) + if navErr != nil { return &buildArtefacts{ o: o, @@ -75,14 +90,16 @@ func (bs *Builders) buildAll() (*buildArtefacts, error) { // INIT PLUGINS // - roles := lo.Map(plugins, func(plugin types.Plugin, _ int) enums.Role { - return plugin.Role() - }) - - artefacts.Mediator.Arrange(roles) + artefacts.Mediator.Arrange(lo.Map(plugins, + func(plugin types.Plugin, _ int) enums.Role { + return plugin.Role() + }, + )) for _, p := range plugins { - if bindErr := p.Init(); bindErr != nil { + if bindErr := p.Init(&types.PluginInit{ + Actions: actions, + }); bindErr != nil { return &buildArtefacts{ o: o, kc: artefacts.Kontroller, diff --git a/collections/positional-set.go b/collections/positional-set.go index 4615fdc..b99ac25 100644 --- a/collections/positional-set.go +++ b/collections/positional-set.go @@ -56,7 +56,7 @@ func (ps *PositionalSet[T]) Insert(item T) bool { return false } -// Add insert multiple items into the set under the same conditions as +// All inserts multiple items into the set under the same conditions as // Insert func (ps *PositionalSet[T]) All(items ...T) bool { result := true @@ -72,7 +72,7 @@ func (ps *PositionalSet[T]) All(items ...T) bool { return result } -// Delete removes an item from the set +// Delete removes an item from the set. Removing the anchor is prohibited. func (ps *PositionalSet[T]) Delete(item T) { if item == ps.anchor { return diff --git a/core/directory-contents.go b/core/directory-contents.go index b10dbbd..3a0a0ab 100644 --- a/core/directory-contents.go +++ b/core/directory-contents.go @@ -2,14 +2,28 @@ package core import ( "io/fs" + + "github.com/snivilised/traverse/enums" ) // DirectoryContents represents the contents of a directory's contents and // handles sorting order which by default is different between various // operating systems. This abstraction removes the differences in sorting // behaviour on different platforms. -type DirectoryContents interface { - All() []fs.DirEntry - Folders() []fs.DirEntry - Files() []fs.DirEntry -} +type ( + DirectoryContents interface { + All() []fs.DirEntry + Folders() []fs.DirEntry + Files() []fs.DirEntry + } + + // Inspection + Inspection interface { + Current() *Node + Contents() DirectoryContents + Entries() []fs.DirEntry + Sort(et enums.EntryType) []fs.DirEntry + Pick(et enums.EntryType) + AssignChildren(children []fs.DirEntry) + } +) diff --git a/core/filtering.go b/core/filtering.go index 8e5649e..1635b2c 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 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..9a07e10 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(_ *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..a31c33f 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(_ *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/go.mod b/go.mod index b3a2909..b46ed04 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( github.com/nicksnyder/go-i18n/v2 v2.4.0 // indirect github.com/pkg/errors v0.9.1 github.com/rogpeppe/go-internal v1.12.0 // indirect - github.com/samber/lo v1.39.0 + github.com/samber/lo v1.39.0 // indirect golang.org/x/net v0.25.0 // indirect golang.org/x/sys v0.20.0 // indirect golang.org/x/text v0.15.0 // indirect diff --git a/internal/helpers/test-utilities.go b/internal/helpers/test-utilities.go index 2111a9f..45ee10e 100644 --- a/internal/helpers/test-utilities.go +++ b/internal/helpers/test-utilities.go @@ -17,10 +17,20 @@ func Normalise(p string) string { return strings.ReplaceAll(p, "/", string(filepath.Separator)) } +func Because(name, because string) string { + return fmt.Sprintf("โŒ for item named: '%v', because: '%v'", name, because) +} + 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/hiber/hibernate-plugin.go b/internal/hiber/hibernate-plugin.go index 4c7d256..0d7333a 100644 --- a/internal/hiber/hibernate-plugin.go +++ b/internal/hiber/hibernate-plugin.go @@ -41,6 +41,6 @@ func (p *Plugin) Next(node *core.Node) (bool, error) { return true, nil } -func (p *Plugin) Init() error { +func (p *Plugin) Init(_ *types.PluginInit) error { return p.Mediator.Decorate(p) } 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/extend.go b/internal/kernel/extend.go index ab8a8cf..acc41cf 100644 --- a/internal/kernel/extend.go +++ b/internal/kernel/extend.go @@ -13,8 +13,8 @@ func extend(ns *navigationStatic, vapour inspection) { var ( scope enums.FilterScope isLeaf bool - current = vapour.current() - contents = vapour.contents() + current = vapour.Current() + contents = vapour.Contents() ) if current.IsFolder() { 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/guardian.go b/internal/kernel/guardian.go index 573e7c7..642c84e 100644 --- a/internal/kernel/guardian.go +++ b/internal/kernel/guardian.go @@ -18,7 +18,7 @@ type ( ) type owned struct { - mums measure.Mutables + mums measure.MutableMetrics } // anchor is a specialised link that should always be the @@ -58,7 +58,7 @@ type guardian struct { func newGuardian(client core.Client, master types.GuardianSealer, - mums measure.Mutables, + mums measure.MutableMetrics, ) *guardian { anchor := &anchor{ client: client, diff --git a/internal/kernel/kernel-defs.go b/internal/kernel/kernel-defs.go index 7cc04b8..da93bb4 100644 --- a/internal/kernel/kernel-defs.go +++ b/internal/kernel/kernel-defs.go @@ -130,10 +130,8 @@ type ( } inspection interface { // after content has been read + core.Inspection static() *navigationStatic - current() *core.Node - contents() core.DirectoryContents - entries() []fs.DirEntry clear() } @@ -147,15 +145,15 @@ func (v *navigationVapour) static() *navigationStatic { return v.ns } -func (v *navigationVapour) current() *core.Node { +func (v *navigationVapour) Current() *core.Node { return v.present } -func (v *navigationVapour) contents() core.DirectoryContents { +func (v *navigationVapour) Contents() core.DirectoryContents { return v.cargo } -func (v *navigationVapour) entries() []fs.DirEntry { +func (v *navigationVapour) Entries() []fs.DirEntry { return v.ents } @@ -167,9 +165,23 @@ func (v *navigationVapour) clear() { } } -func (v *navigationVapour) sort(et enums.EntryType) { +func (v *navigationVapour) Sort(et enums.EntryType) []fs.DirEntry { v.cargo.Sort(et) + // change SortHook to return entries so we don't have to do this switch? + switch et { + case enums.EntryTypeAll: + return v.cargo.All() + case enums.EntryTypeFolder: + return v.cargo.folders + case enums.EntryTypeFile: + return v.cargo.files + } + + return nil +} + +func (v *navigationVapour) Pick(et enums.EntryType) { switch et { case enums.EntryTypeAll: v.ents = v.cargo.All() @@ -180,6 +192,10 @@ func (v *navigationVapour) sort(et enums.EntryType) { } } +func (v *navigationVapour) AssignChildren(children []fs.DirEntry) { + v.present.Children = children +} + type NodeInvoker func(node *core.Node) error func (fn NodeInvoker) Invoke(node *core.Node) error { diff --git a/internal/kernel/kernel-suite_test.go b/internal/kernel/kernel-suite_test.go index b2070cb..4d2d75d 100644 --- a/internal/kernel/kernel-suite_test.go +++ b/internal/kernel/kernel-suite_test.go @@ -1,13 +1,149 @@ package kernel_test import ( + "fmt" + "io/fs" + "strings" "testing" . "github.com/onsi/ginkgo/v2" //nolint:revive // ok . "github.com/onsi/gomega" //nolint:revive // ok + "github.com/snivilised/traverse/core" + "github.com/snivilised/traverse/cycle" + "github.com/snivilised/traverse/enums" + "github.com/snivilised/traverse/internal/helpers" + "github.com/snivilised/traverse/internal/lo" ) func TestKernel(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Kernel Suite") } + +const ( + RootPath = "traversal-root-path" + RestorePath = "/from-restore-path" +) + +type recordingMap map[string]int +type recordingScopeMap map[string]enums.FilterScope +type recordingOrderMap map[string]int + +type quantities struct { + files uint + folders uint + children map[string]int +} + +type naviTE struct { + message string + should string + relative string + once bool + visit bool + caseSensitive bool + subscription enums.Subscription + callback core.Client + mandatory []string + prohibited []string + 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(state *cycle.BeginState) { + GinkgoWriter.Printf( + "---> %v [traverse-navigator-test:BEGIN], root: '%v'\n", em, state.Root, + ) + } +} + +func universalCallback(name string) core.Client { + return func(node *core.Node) error { + depth := node.Extension.Depth + GinkgoWriter.Printf( + "---> ๐ŸŒŠ UNIVERSAL//%v-CALLBACK: (depth:%v) '%v'\n", name, depth, node.Path, + ) + Expect(node.Extension).NotTo(BeNil(), helpers.Reason(node.Path)) + + return nil + } +} + +func foldersCallback(name string) core.Client { + return func(node *core.Node) error { + depth := node.Extension.Depth + actualNoChildren := len(node.Children) + GinkgoWriter.Printf( + "---> ๐Ÿ”† FOLDERS//CALLBACK%v: (depth:%v, children:%v) '%v'\n", + name, depth, actualNoChildren, node.Path, + ) + Expect(node.IsFolder()).To(BeTrue(), + helpers.Because(node.Path, "node expected to be folder"), + ) + Expect(node.Extension).NotTo(BeNil(), helpers.Reason(node.Path)) + + return nil + } +} + +func filesCallback(name string) core.Client { + return func(node *core.Node) error { + GinkgoWriter.Printf("---> ๐ŸŒ™ FILES//%v-CALLBACK: '%v'\n", name, node.Path) + Expect(node.IsFolder()).To(BeFalse(), + helpers.Because(node.Path, "node expected to be file"), + ) + Expect(node.Extension).NotTo(BeNil(), helpers.Reason(node.Path)) + + return nil + } +} + +func foldersCaseSensitiveCallback(first, second string) core.Client { + recording := make(recordingMap) + + return func(node *core.Node) error { + recording[node.Path] = len(node.Children) + + GinkgoWriter.Printf("---> ๐Ÿ”† CASE-SENSITIVE-CALLBACK: '%v'\n", node.Path) + Expect(node.IsFolder()).To(BeTrue()) + + if strings.HasSuffix(node.Path, second) { + GinkgoWriter.Printf("---> ๐Ÿ’ง FIRST: '%v', ๐Ÿ’ง SECOND: '%v'\n", first, second) + + paths := lo.Keys(recording) + _, found := lo.Find(paths, func(s string) bool { + return strings.HasSuffix(s, first) + }) + + Expect(found).To(BeTrue(), fmt.Sprintf("for node: '%v'", node.Extension.Name)) + } + + return nil + } +} + +func subscribes(subscription enums.Subscription, de fs.DirEntry) bool { + isAnySubscription := (subscription == enums.SubscribeUniversal) + files := de != nil && (subscription == enums.SubscribeFiles) && (!de.IsDir()) + folders := de != nil && ((subscription == enums.SubscribeFolders) || + subscription == enums.SubscribeFoldersWithFiles) && (de.IsDir()) + + return isAnySubscription || files || folders +} diff --git a/internal/kernel/kernel-support_test.go b/internal/kernel/kernel-support_test.go deleted file mode 100644 index 8a03fc3..0000000 --- a/internal/kernel/kernel-support_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package kernel_test - -import ( - "fmt" - "io/fs" - "strings" - - . "github.com/onsi/ginkgo/v2" //nolint:revive // ok - . "github.com/onsi/gomega" //nolint:revive // ok - "github.com/samber/lo" - "github.com/snivilised/traverse/core" - "github.com/snivilised/traverse/cycle" - "github.com/snivilised/traverse/enums" - "github.com/snivilised/traverse/internal/helpers" -) - -const ( - RootPath = "traversal-root-path" - RestorePath = "/from-restore-path" -) - -type recordingMap map[string]int -type recordingScopeMap map[string]enums.FilterScope -type recordingOrderMap map[string]int - -type quantities struct { - files uint - folders uint - children map[string]int -} - -type naviTE struct { - message string - should string - relative string - once bool - visit bool - caseSensitive bool - subscription enums.Subscription - callback core.Client - mandatory []string - prohibited []string - expectedNoOf quantities -} - -func begin(em string) cycle.BeginHandler { - return func(root string) { - GinkgoWriter.Printf( - "---> %v [traverse-navigator-test:BEGIN], root: '%v'\n", em, root, - ) - } -} - -func universalCallback(name string) core.Client { - return func(node *core.Node) error { - depth := node.Extension.Depth - GinkgoWriter.Printf( - "---> ๐ŸŒŠ UNIVERSAL//%v-CALLBACK: (depth:%v) '%v'\n", name, depth, node.Path, - ) - Expect(node.Extension).NotTo(BeNil(), helpers.Reason(node.Path)) - - return nil - } -} - -func foldersCallback(name string) core.Client { - return func(node *core.Node) error { - depth := node.Extension.Depth - actualNoChildren := len(node.Children) - GinkgoWriter.Printf( - "---> ๐Ÿ”† FOLDERS//CALLBACK%v: (depth:%v, children:%v) '%v'\n", - name, depth, actualNoChildren, node.Path, - ) - Expect(node.IsFolder()).To(BeTrue()) - Expect(node.Extension).NotTo(BeNil(), helpers.Reason(node.Path)) - - return nil - } -} - -func filesCallback(name string) core.Client { - return func(node *core.Node) error { - GinkgoWriter.Printf("---> ๐ŸŒ™ FILES//%v-CALLBACK: '%v'\n", name, node.Path) - Expect(node.IsFolder()).To(BeFalse()) - Expect(node.Extension).NotTo(BeNil(), helpers.Reason(node.Path)) - - return nil - } -} - -func foldersCaseSensitiveCallback(first, second string) core.Client { - recording := make(recordingMap) - - return func(node *core.Node) error { - recording[node.Path] = len(node.Children) - - GinkgoWriter.Printf("---> ๐Ÿ”† CASE-SENSITIVE-CALLBACK: '%v'\n", node.Path) - Expect(node.IsFolder()).To(BeTrue()) - - if strings.HasSuffix(node.Path, second) { - GinkgoWriter.Printf("---> ๐Ÿ’ง FIRST: '%v', ๐Ÿ’ง SECOND: '%v'\n", first, second) - - paths := lo.Keys(recording) - _, found := lo.Find(paths, func(s string) bool { - return strings.HasSuffix(s, first) - }) - - Expect(found).To(BeTrue(), fmt.Sprintf("for node: '%v'", node.Extension.Name)) - } - - return nil - } -} - -func subscribes(subscription enums.Subscription, de fs.DirEntry) bool { - isAnySubscription := (subscription == enums.SubscribeUniversal) - files := de != nil && (subscription == enums.SubscribeFiles) && (!de.IsDir()) - folders := de != nil && ((subscription == enums.SubscribeFolders) || - subscription == enums.SubscribeFoldersWithFiles) && (de.IsDir()) - - return isAnySubscription || files || folders -} diff --git a/internal/kernel/mediator.go b/internal/kernel/mediator.go index 7b51656..5ad008f 100644 --- a/internal/kernel/mediator.go +++ b/internal/kernel/mediator.go @@ -14,14 +14,15 @@ import ( // 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 + o *pref.Options + resources *types.Resources + mums measure.MutableMetrics } func newMediator(using *pref.Using, @@ -30,20 +31,24 @@ func newMediator(using *pref.Using, sealer types.GuardianSealer, resources *types.Resources, ) *mediator { + mums := resources.Supervisor.Many( + enums.MetricNoFilesInvoked, + enums.MetricNoFoldersInvoked, + enums.MetricNoChildFilesFound, + ) + return &mediator{ - root: using.Root, - using: using, - impl: impl, - guardian: newGuardian(using.Handler, sealer, resources.Supervisor.Many( - enums.MetricNoFilesInvoked, - enums.MetricNoFoldersInvoked, - )), + root: using.Root, + subscription: using.Subscription, + using: using, + impl: impl, + guardian: newGuardian(using.Handler, sealer, mums), frame: &navigationFrame{ periscope: level.New(), }, - pad: newScratch(o), o: o, resources: resources, + mums: mums, } } 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-agent.go b/internal/kernel/navigator-agent.go index 6122416..9bac113 100644 --- a/internal/kernel/navigator-agent.go +++ b/internal/kernel/navigator-agent.go @@ -116,10 +116,10 @@ func (n *navigatorAgent) travel(ctx context.Context, vapour inspection, ) (bool, error) { var ( - parent = vapour.current() + parent = vapour.Current() ) - for _, entry := range vapour.entries() { + for _, entry := range vapour.Entries() { path := filepath.Join(parent.Path, entry.Name()) info, e := entry.Info() diff --git a/internal/kernel/navigator-factory.go b/internal/kernel/navigator-factory.go index b7cbcfa..32b51f4 100644 --- a/internal/kernel/navigator-factory.go +++ b/internal/kernel/navigator-factory.go @@ -16,26 +16,17 @@ func New(using *pref.Using, o *pref.Options, resources *types.Resources, ) *Artefacts { impl := newImpl(using, o, resources) - controller := newController(using, o, impl, sealer, resources) + controller := &NavigationController{ + Med: newMediator(using, o, impl, sealer, resources), + } return &Artefacts{ Kontroller: controller, - Mediator: controller.Mediator, + Mediator: controller.Med, Resources: resources, } } -func newController(using *pref.Using, - o *pref.Options, - impl NavigatorImpl, - sealer types.GuardianSealer, - resources *types.Resources, -) *NavigationController { - return &NavigationController{ - Mediator: newMediator(using, o, impl, sealer, resources), - } -} - func newImpl(using *pref.Using, o *pref.Options, resources *types.Resources, diff --git a/internal/kernel/navigator-files.go b/internal/kernel/navigator-files.go index 21e9d5e..a06a0d8 100644 --- a/internal/kernel/navigator-files.go +++ b/internal/kernel/navigator-files.go @@ -47,7 +47,7 @@ func (n *navigatorFiles) Travel(ctx context.Context, } if skip, e := ns.mediator.o.Defects.Skip.Ask( - current, vapour.contents(), err, + current, vapour.Contents(), err, ); skip == enums.SkipAllTraversal || err != nil { return continueTraversal, e } else if skip == enums.SkipDirTraversal { @@ -57,7 +57,9 @@ func (n *navigatorFiles) Travel(ctx context.Context, return n.travel(ctx, ns, vapour) } -func (n *navigatorFiles) inspect(ns *navigationStatic, current *core.Node) (inspection, error) { +func (n *navigatorFiles) inspect(ns *navigationStatic, + current *core.Node, +) (inspection, error) { var ( vapour = &navigationVapour{ ns: ns, @@ -72,7 +74,8 @@ func (n *navigatorFiles) inspect(ns *navigationStatic, current *core.Node) (insp current.Path, ) - vapour.sort(enums.EntryTypeAll) + vapour.Sort(enums.EntryTypeAll) + vapour.Pick(enums.EntryTypeAll) } else { vapour.clear() } diff --git a/internal/kernel/navigator-filter-extended-glob_test.go b/internal/kernel/navigator-filter-extended-glob_test.go new file mode 100644 index 0000000..d0b3182 --- /dev/null +++ b/internal/kernel/navigator-filter-extended-glob_test.go @@ -0,0 +1,490 @@ +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("NavigatorFoldersWithFiles", Ordered, func() { + var ( + vfs fstest.MapFS + root string + ) + + BeforeAll(func() { + const ( + verbose = true + ) + + vfs, root = helpers.Musico(verbose, + filepath.Join("MUSICO", "rock"), + ) + Expect(root).NotTo(BeEmpty()) + }) + + BeforeEach(func() { + services.Reset() + }) + + DescribeTable("folders with files filtered", + func(ctx SpecContext, entry *filterTE) { + recording := make(recordingMap) + filterDefs := &pref.FilterOptions{ + Node: &core.FilterDef{ + Type: enums.FilterTypeExtendedGlob, + 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(node *core.Node) error { + indicator := lo.Ternary(node.IsFolder(), "๐Ÿ“", "๐Ÿ’ ") + GinkgoWriter.Printf( + "===> %v Glob Filter(%v) source: '%v', item-name: '%v', item-scope(fs): '%v(%v)'\n", + indicator, + traverseFilter.Description(), + traverseFilter.Source(), + node.Extension.Name, + node.Extension.Scope, + traverseFilter.Scope(), + ) + if lo.Contains(entry.mandatory, node.Extension.Name) { + Expect(node).Should(MatchCurrentExtendedFilter(traverseFilter)) + } + + recording[node.Extension.Name] = len(node.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) + }, + + // === universal ===================================================== + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "universal(any scope): extended glob filter", + relative: "rock/PROGRESSIVE-ROCK/Marillion", + subscription: enums.SubscribeUniversal, + expectedNoOf: quantities{ + files: 16, + folders: 5, + }, + prohibited: []string{"cover-clutching-at-straws-jpg"}, + }, + name: "items with 'flac' suffix", + pattern: "*|flac", + scope: enums.ScopeAll, + }), + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "universal(any scope): extended glob filter, with dot extension", + relative: "rock/PROGRESSIVE-ROCK/Marillion", + subscription: enums.SubscribeUniversal, + expectedNoOf: quantities{ + files: 16, + folders: 5, + }, + prohibited: []string{"cover-clutching-at-straws-jpg"}, + }, + name: "items with 'flac' suffix", + pattern: "*|.flac", + scope: enums.ScopeAll, + }), + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "universal(any scope): extended glob filter, with multiple extensions", + relative: "rock/PROGRESSIVE-ROCK/Marillion", + subscription: enums.SubscribeUniversal, + expectedNoOf: quantities{ + files: 19, + folders: 5, + }, + mandatory: []string{"front.jpg"}, + prohibited: []string{"cover-clutching-at-straws-jpg"}, + }, + name: "items with 'flac' suffix", + pattern: "*|flac,jpg", + scope: enums.ScopeAll, + }), + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "universal(any scope): extended glob filter, without extension", + relative: "rock/PROGRESSIVE-ROCK/Marillion", + subscription: enums.SubscribeUniversal, + expectedNoOf: quantities{ + files: 3, + folders: 5, + }, + mandatory: []string{"cover-clutching-at-straws-jpg"}, + prohibited: []string{"01 - Hotel Hobbies.flac"}, + }, + name: "items with 'flac' suffix", + pattern: "*|", + scope: enums.ScopeAll, + }), + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "universal(file scope): extended glob filter (negate)", + relative: "rock/PROGRESSIVE-ROCK/Marillion", + subscription: enums.SubscribeUniversal, + expectedNoOf: quantities{ + files: 7, + folders: 5, + }, + prohibited: []string{"01 - Hotel Hobbies.flac"}, + }, + name: "files without .flac suffix", + pattern: "*|flac", + scope: enums.ScopeFile, + negate: true, + }), + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "universal(undefined scope): extended glob filter", + relative: "rock/PROGRESSIVE-ROCK/Marillion", + subscription: enums.SubscribeUniversal, + expectedNoOf: quantities{ + files: 16, + folders: 5, + }, + prohibited: []string{"cover-clutching-at-straws-jpg"}, + }, + name: "items with '.flac' suffix", + pattern: "*|flac", + }), + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "universal(any scope): extended glob filter, any extension", + relative: "rock/PROGRESSIVE-ROCK/Marillion", + subscription: enums.SubscribeUniversal, + expectedNoOf: quantities{ + files: 4, + folders: 1, + }, + mandatory: []string{"cover-clutching-at-straws-jpg"}, + prohibited: []string{"01 - Hotel Hobbies.flac"}, + }, + name: "starts with c, any extension", + pattern: "c*|*", + scope: enums.ScopeAll, + }), + + // === folders ======================================================= + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "folders(any scope): extended glob filter", + relative: "rock/PROGRESSIVE-ROCK/Marillion", + subscription: enums.SubscribeFolders, + expectedNoOf: quantities{ + files: 0, + folders: 2, + }, + mandatory: []string{"Marillion"}, + prohibited: []string{"Fugazi"}, + }, + name: "folders starting with M", + pattern: "M*|", + scope: enums.ScopeFolder, + }), + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "folders(folder scope): extended glob filter (negate)", + relative: "rock/PROGRESSIVE-ROCK/Marillion", + subscription: enums.SubscribeFolders, + expectedNoOf: quantities{ + files: 0, + folders: 3, + }, + mandatory: []string{"Fugazi"}, + prohibited: []string{"Marillion"}, + }, + name: "folders NOT starting with M", + pattern: "M*|", + scope: enums.ScopeFolder, + negate: true, + }), + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "universal(undefined scope): extended glob filter", + relative: "rock/PROGRESSIVE-ROCK/Marillion", + subscription: enums.SubscribeFolders, + expectedNoOf: quantities{ + files: 0, + folders: 2, + }, + mandatory: []string{"Marillion"}, + prohibited: []string{"Fugazi"}, + }, + name: "folders starting with M", + pattern: "M*|", + }), + + // === files ========================================================= + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "files(file scope): extended glob filter", + relative: "rock/PROGRESSIVE-ROCK/Marillion", + subscription: enums.SubscribeFiles, + expectedNoOf: quantities{ + files: 16, + folders: 0, + }, + mandatory: []string{"01 - Hotel Hobbies.flac"}, + prohibited: []string{"cover-clutching-at-straws-jpg"}, + }, + name: "items with 'flac' suffix", + pattern: "*|flac", + scope: enums.ScopeFile, + }), + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "files(any scope): extended glob filter, with dot extension", + relative: "rock/PROGRESSIVE-ROCK/Marillion", + subscription: enums.SubscribeFiles, + expectedNoOf: quantities{ + files: 16, + folders: 0, + }, + mandatory: []string{"01 - Hotel Hobbies.flac"}, + prohibited: []string{"cover-clutching-at-straws-jpg"}, + }, + name: "items with 'flac' suffix", + pattern: "*|.flac", + scope: enums.ScopeFile, + }), + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "files(file scope): extended glob filter, with multiple extensions", + relative: "rock/PROGRESSIVE-ROCK/Marillion", + subscription: enums.SubscribeFiles, + expectedNoOf: quantities{ + files: 19, + folders: 0, + }, + mandatory: []string{"front.jpg"}, + prohibited: []string{"cover-clutching-at-straws-jpg"}, + }, + name: "items with 'flac' suffix", + pattern: "*|flac,jpg", + scope: enums.ScopeFile, + }), + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "file(file scope): extended glob filter, without extension", + relative: "rock/PROGRESSIVE-ROCK/Marillion", + subscription: enums.SubscribeFiles, + expectedNoOf: quantities{ + files: 3, + folders: 0, + }, + mandatory: []string{"cover-clutching-at-straws-jpg"}, + prohibited: []string{"01 - Hotel Hobbies.flac"}, + }, + name: "items with 'flac' suffix", + pattern: "*|", + scope: enums.ScopeFile, + }), + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "file(file scope): extended glob filter (negate)", + relative: "rock/PROGRESSIVE-ROCK/Marillion", + subscription: enums.SubscribeFiles, + expectedNoOf: quantities{ + files: 7, + folders: 0, + }, + mandatory: []string{"cover-clutching-at-straws-jpg"}, + prohibited: []string{"01 - Hotel Hobbies.flac"}, + }, + name: "files without .flac suffix", + pattern: "*|flac", + scope: enums.ScopeFile, + negate: true, + }), + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "file(undefined scope): extended glob filter", + relative: "rock/PROGRESSIVE-ROCK/Marillion", + subscription: enums.SubscribeFiles, + expectedNoOf: quantities{ + files: 16, + folders: 0, + }, + mandatory: []string{"01 - Hotel Hobbies.flac"}, + prohibited: []string{"cover-clutching-at-straws-jpg"}, + }, + name: "items with '.flac' suffix", + pattern: "*|flac", + }), + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "file(any scope): extended glob filter, any extension", + relative: "rock/PROGRESSIVE-ROCK/Marillion", + subscription: enums.SubscribeFiles, + expectedNoOf: quantities{ + files: 4, + folders: 0, + }, + mandatory: []string{"cover-clutching-at-straws-jpg"}, + prohibited: []string{"01 - Hotel Hobbies.flac"}, + }, + name: "starts with c, any extension", + pattern: "c*|*", + scope: enums.ScopeAll, + }), + + // === ifNotApplicable =============================================== + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "universal(leaf scope): extended glob filter (ifNotApplicable=true)", + relative: "rock/PROGRESSIVE-ROCK/Marillion", + subscription: enums.SubscribeUniversal, + expectedNoOf: quantities{ + files: 16, + folders: 5, + }, + mandatory: []string{"Marillion"}, + prohibited: []string{"cover-clutching-at-straws-jpg"}, + }, + name: "leaf items with 'flac' suffix", + pattern: "*|flac", + scope: enums.ScopeLeaf, + ifNotApplicable: enums.TriStateBoolTrue, + }), + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "universal(leaf scope): extended glob filter (ifNotApplicable=false)", + relative: "rock/PROGRESSIVE-ROCK/Marillion", + subscription: enums.SubscribeUniversal, + expectedNoOf: quantities{ + files: 16, + folders: 4, + }, + prohibited: []string{"Marillion"}, + }, + name: "items with '.flac' suffix", + pattern: "*|flac", + scope: enums.ScopeLeaf, + ifNotApplicable: enums.TriStateBoolFalse, + }), + + // === with-exclusion ================================================ + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "universal(any scope): extended glob filter with exclusion", + relative: "rock/PROGRESSIVE-ROCK/Marillion", + subscription: enums.SubscribeFiles, + expectedNoOf: quantities{ + files: 12, + folders: 0, + }, + prohibited: []string{"01 - Hotel Hobbies.flac"}, + }, + name: "files starting with 0, except 01 items and flac suffix", + pattern: "0*/*01*|flac", + scope: enums.ScopeFile, + }), + ) +}) diff --git a/internal/kernel/navigator-filter-glob_test.go b/internal/kernel/navigator-filter-glob_test.go new file mode 100644 index 0000000..11edba9 --- /dev/null +++ b/internal/kernel/navigator-filter-glob_test.go @@ -0,0 +1,227 @@ +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(node *core.Node) error { + indicator := lo.Ternary(node.IsFolder(), "๐Ÿ“", "๐Ÿ’ ") + GinkgoWriter.Printf( + "===> %v Glob Filter(%v) source: '%v', item-name: '%v', item-scope(fs): '%v(%v)'\n", + indicator, + traverseFilter.Description(), + traverseFilter.Source(), + node.Extension.Name, + node.Extension.Scope, + traverseFilter.Scope(), + ) + if lo.Contains(entry.mandatory, node.Extension.Name) { + Expect(node).Should(MatchCurrentGlobFilter(traverseFilter)) + } + + recording[node.Extension.Name] = len(node.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, + }), + ) + +}) diff --git a/internal/kernel/navigator-filter-regex_test.go b/internal/kernel/navigator-filter-regex_test.go new file mode 100644 index 0000000..99d3175 --- /dev/null +++ b/internal/kernel/navigator-filter-regex_test.go @@ -0,0 +1,265 @@ +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("NavigatorFilterRegex", Ordered, func() { + var ( + vfs fstest.MapFS + root string + ) + + BeforeAll(func() { + const ( + verbose = true + ) + + vfs, root = helpers.Musico(verbose, + filepath.Join("MUSICO", "RETRO-WAVE"), + filepath.Join("MUSICO", "PROGRESSIVE-HOUSE"), + ) + Expect(root).NotTo(BeEmpty()) + }) + + BeforeEach(func() { + services.Reset() + }) + + DescribeTable("regex-filter", + func(ctx SpecContext, entry *filterTE) { + recording := make(recordingMap) + filterDefs := &pref.FilterOptions{ + Node: &core.FilterDef{ + Type: enums.FilterTypeRegex, + 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(MatchCurrentRegexFilter(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).To(Succeed()) + + 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)), + ), + ) + }, + func(entry *filterTE) string { + return fmt.Sprintf("๐Ÿงช ===> given: '%v'", entry.message) + }, + + // === files ========================================================= + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "files(any scope): regex filter", + relative: "RETRO-WAVE", + subscription: enums.SubscribeFiles, + expectedNoOf: quantities{ + files: 4, + folders: 0, + }, + }, + name: "items that start with 'vinyl'", + pattern: "^vinyl", + scope: enums.ScopeAll, + }), + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "files(any scope): regex filter (negate)", + relative: "RETRO-WAVE", + subscription: enums.SubscribeFiles, + expectedNoOf: quantities{ + files: 10, + folders: 0, + }, + }, + name: "items that don't start with 'vinyl'", + pattern: "^vinyl", + scope: enums.ScopeAll, + negate: true, + }), + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "files(default to any scope): regex filter", + relative: "RETRO-WAVE", + subscription: enums.SubscribeFiles, + expectedNoOf: quantities{ + files: 4, + folders: 0, + }, + }, + name: "items that start with 'vinyl'", + pattern: "^vinyl", + }), + + // === folders ======================================================= + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "folders(any scope): regex filter", + relative: "RETRO-WAVE", + subscription: enums.SubscribeFolders, + expectedNoOf: quantities{ + files: 0, + folders: 2, + }, + }, + name: "items that start with 'C'", + pattern: "^C", + scope: enums.ScopeAll, + }), + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "folders(any scope): regex filter (negate)", + relative: "RETRO-WAVE", + subscription: enums.SubscribeFolders, + expectedNoOf: quantities{ + files: 0, + folders: 6, + }, + }, + name: "items that don't start with 'C'", + pattern: "^C", + scope: enums.ScopeAll, + negate: true, + }), + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "folders(undefined scope): regex filter", + relative: "RETRO-WAVE", + subscription: enums.SubscribeFolders, + expectedNoOf: quantities{ + files: 0, + folders: 2, + }, + }, + name: "items that start with 'C'", + pattern: "^C", + }), + + // === ifNotApplicable =============================================== + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "folders(top): regex filter (ifNotApplicable=true)", + relative: "PROGRESSIVE-HOUSE", + subscription: enums.SubscribeFolders, + expectedNoOf: quantities{ + files: 0, + folders: 10, + }, + mandatory: []string{"PROGRESSIVE-HOUSE"}, + }, + name: "top items that contain 'HOUSE'", + pattern: "HOUSE", + scope: enums.ScopeTop, + ifNotApplicable: enums.TriStateBoolTrue, + }), + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "folders(top): regex filter (ifNotApplicable=false)", + relative: "", + subscription: enums.SubscribeFolders, + mandatory: []string{"PROGRESSIVE-HOUSE"}, + expectedNoOf: quantities{ + files: 0, + folders: 1, + }, + prohibited: []string{"Blue Amazon", "The Javelin"}, + }, + name: "top items that contain 'HOUSE'", + pattern: "HOUSE", + scope: enums.ScopeTop, + ifNotApplicable: enums.TriStateBoolFalse, + }), + ) +}) diff --git a/internal/kernel/navigator-folders-with-files-filtered_test.go b/internal/kernel/navigator-folders-with-files-filtered_test.go new file mode 100644 index 0000000..69396d8 --- /dev/null +++ b/internal/kernel/navigator-folders-with-files-filtered_test.go @@ -0,0 +1,180 @@ +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/services" + "github.com/snivilised/traverse/pref" +) + +var _ = Describe("NavigatorFoldersWithFiles", 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("folders with files filtered", + func(ctx SpecContext, entry *filterTE) { + recording := make(recordingMap) + filterDefs := &pref.FilterOptions{ + Child: &core.ChildFilterDef{ + Type: enums.FilterTypeGlob, + Description: entry.name, + Pattern: entry.pattern, + Negate: entry.negate, + }, + } + var childFilter core.ChildTraverseFilter + path := helpers.Path(root, entry.relative) + + callback := func(item *core.Node) error { + actualNoChildren := len(item.Children) + GinkgoWriter.Printf( + "===> ๐Ÿ’  Compound Glob Filter(%v, children: %v) source: '%v', node-name: '%v', node-scope: '%v', depth: '%v'\n", + childFilter.Description(), + actualNoChildren, + childFilter.Source(), + item.Extension.Name, + item.Extension.Scope, + item.Extension.Depth, + ) + + 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(_ core.TraverseFilter, filter core.ChildTraverseFilter) { + childFilter = 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) + + Expect(err).To(Succeed()) + + 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)) + } + } + + for n, actualNoChildren := range entry.expectedNoOf.children { + expected := recording[n] + + Expect(expected).To(Equal(actualNoChildren), + helpers.BecauseQuantity("Incorrect no of children", + expected, + actualNoChildren, + ), + ) + } + + 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)), + ), + ) + }, + func(entry *filterTE) string { + return fmt.Sprintf("๐Ÿงช ===> given: '%v'", entry.message) + }, + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "folder(with files): glob filter", + relative: "RETRO-WAVE", + subscription: enums.SubscribeFoldersWithFiles, + expectedNoOf: quantities{ + files: 0, + folders: 8, + children: map[string]int{ + "Night Drive": 2, + "Northern Council": 2, + "Teenage Color": 2, + "Innerworld": 2, + }, + }, + }, + name: "items with '.flac' suffix", + pattern: "*.flac", + }), + + Entry(nil, &filterTE{ + naviTE: naviTE{ + message: "folder(with files): glob filter (negate)", + relative: "RETRO-WAVE", + subscription: enums.SubscribeFoldersWithFiles, + expectedNoOf: quantities{ + files: 0, + folders: 8, + children: map[string]int{ + "Night Drive": 3, + "Northern Council": 3, + "Teenage Color": 2, + "Innerworld": 2, + }, + }, + }, + name: "items without '.txt' suffix", + pattern: "*.txt", + negate: true, + }), + ) +}) diff --git a/internal/kernel/navigator-folders-with-files_test.go b/internal/kernel/navigator-folders-with-files_test.go index 2ca779b..be36688 100644 --- a/internal/kernel/navigator-folders-with-files_test.go +++ b/internal/kernel/navigator-folders-with-files_test.go @@ -1,41 +1,173 @@ package kernel_test import ( - "context" + "fmt" + "io/fs" + "path/filepath" + "strings" + "testing/fstest" - "github.com/fortytw2/leaktest" . "github.com/onsi/ginkgo/v2" //nolint:revive // ok . "github.com/onsi/gomega" //nolint:revive // ok tv "github.com/snivilised/traverse" + "github.com/snivilised/traverse/enums" + "github.com/snivilised/traverse/internal/helpers" + "github.com/snivilised/traverse/internal/lo" "github.com/snivilised/traverse/internal/services" ) -var _ = Describe("NavigatorFoldersWithFiles", func() { +var _ = Describe("NavigatorFoldersWithFiles", 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() }) - Context("nav", func() { - When("foo", func() { - It("๐Ÿงช should: not fail", func(specCtx SpecContext) { - defer leaktest.Check(GinkgoT())() + Context("glob", func() { + DescribeTable("Filter Children (glob)", + func(ctx SpecContext, entry *naviTE) { + recording := make(recordingMap) + visited := []string{} - ctx, cancel := context.WithCancel(specCtx) - defer cancel() + once := func(node *tv.Node) error { + _, found := recording[node.Extension.Name] + Expect(found).To(BeFalse()) + recording[node.Extension.Name] = len(node.Children) - _, err := tv.Walk().Configure().Extent(tv.Prime( + return entry.callback(node) + } + path := helpers.Path(root, entry.relative) + result, err := tv.Walk().Configure().Extent(tv.Prime( &tv.Using{ - Root: RootPath, - Subscription: tv.SubscribeFoldersWithFiles, - Handler: func(_ *tv.Node) error { - return nil + Root: path, + Subscription: entry.subscription, + Handler: once, + GetFS: func() fs.FS { + return vfs }, }, + tv.If(entry.caseSensitive, tv.WithHookCaseSensitiveSort()), + 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) Expect(err).To(Succeed()) - }) - }) + + if entry.visit { + _ = filepath.WalkDir(path, func(path string, de fs.DirEntry, _ error) error { + if subscribes(entry.subscription, de) { + visited = append(visited, path) + } + + return nil + }) + + every := lo.EveryBy(visited, func(p string) bool { + segments := strings.Split(p, string(filepath.Separator)) + name, err := lo.Last(segments) + + if err == nil { + _, found := recording[name] + return found + } + + return false + }) + Expect(every).To(BeTrue()) + } + + for n, actualNoChildren := range entry.expectedNoOf.children { + expected := recording[n] + Expect(recording[n]).To(Equal(actualNoChildren), + helpers.BecauseQuantity(fmt.Sprintf("folder: '%v'", n), + expected, + actualNoChildren, + ), + ) + } + + 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 *naviTE) string { + return fmt.Sprintf("๐Ÿงช ===> given: '%v'", entry.message) + }, + + // === folders (with files) ========================================== + + Entry(nil, &naviTE{ + message: "folders(with files): Path is leaf", + relative: "RETRO-WAVE/Chromatics/Night Drive", + subscription: enums.SubscribeFoldersWithFiles, + callback: foldersCallback("LEAF-PATH"), + expectedNoOf: quantities{ + files: 0, + folders: 1, + children: map[string]int{ + "Night Drive": 4, + }, + }, + }), + + Entry(nil, &naviTE{ + message: "folders(with files): Path contains folders (check all invoked)", + relative: "RETRO-WAVE", + visit: true, + subscription: enums.SubscribeFoldersWithFiles, + expectedNoOf: quantities{ + files: 0, + folders: 8, + children: map[string]int{ + "Night Drive": 4, + "Northern Council": 4, + "Teenage Color": 3, + "Innerworld": 3, + }, + }, + callback: foldersCallback("CONTAINS-FOLDERS (check all invoked)"), + }), + ) }) }) diff --git a/internal/kernel/navigator-folders.go b/internal/kernel/navigator-folders.go index 018be79..c7f94d4 100644 --- a/internal/kernel/navigator-folders.go +++ b/internal/kernel/navigator-folders.go @@ -39,7 +39,7 @@ func (n *navigatorFolders) Travel(ctx context.Context, } if skip, e := ns.mediator.o.Defects.Skip.Ask( - current, vapour.contents(), err, + current, vapour.Contents(), err, ); skip == enums.SkipAllTraversal { return continueTraversal, e } @@ -59,17 +59,23 @@ 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 forced to use + // enums.DirectoryEntryOrderFoldersFirst instead. // vapour.cargo, err = read(ns.mediator.resources.FS.N, n.ro, current.Path, ) - vapour.sort(enums.EntryTypeFolder) + vapour.Sort(enums.EntryTypeFolder) + vapour.Pick(enums.EntryTypeFolder) - // TODO: implement directory with files + if n.using.Subscription == enums.SubscribeFoldersWithFiles { + ns.mediator.resources.Actions.HandleChildren.Invoke()( + vapour, ns.mediator.mums, + ) + } extend(ns, vapour) 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/kernel/navigator-simple_test.go b/internal/kernel/navigator-simple_test.go index e2b7309..ef815bd 100644 --- a/internal/kernel/navigator-simple_test.go +++ b/internal/kernel/navigator-simple_test.go @@ -45,7 +45,7 @@ var _ = Describe("NavigatorUniversal", Ordered, func() { visited := []string{} once := func(node *tv.Node) error { - _, found := recording[node.Path] + _, found := recording[node.Path] // should this be name not path? Expect(found).To(BeFalse()) recording[node.Path] = len(node.Children) diff --git a/internal/kernel/navigator-universal.go b/internal/kernel/navigator-universal.go index 0b4ebb6..611f7ab 100644 --- a/internal/kernel/navigator-universal.go +++ b/internal/kernel/navigator-universal.go @@ -39,7 +39,7 @@ func (n *navigatorUniversal) Travel(ctx context.Context, } if skip, e := ns.mediator.o.Defects.Skip.Ask( - current, vapour.contents(), err, + current, vapour.Contents(), err, ); skip == enums.SkipAllTraversal { return continueTraversal, e } else if skip == enums.SkipDirTraversal { @@ -64,7 +64,8 @@ func (n *navigatorUniversal) inspect(ns *navigationStatic, current *core.Node) ( current.Path, ) - vapour.sort(enums.EntryTypeAll) + vapour.Sort(enums.EntryTypeAll) + vapour.Pick(enums.EntryTypeAll) } else { vapour.clear() } diff --git a/internal/kernel/scratch-pad.go b/internal/kernel/scratch-pad.go deleted file mode 100644 index 68bfe37..0000000 --- a/internal/kernel/scratch-pad.go +++ /dev/null @@ -1,19 +0,0 @@ -package kernel - -import ( - "github.com/snivilised/traverse/pref" -) - -// scratchPad contains core data that is derived from the options. Any -// decoration that occurs happens on the scratch pad, this way we can -// leave the options to remain unchanged so it always reflects what the -// client set. -type scratchPad struct { - o *pref.Options -} - -func newScratch(o *pref.Options) *scratchPad { - return &scratchPad{ - o: o, - } -} diff --git a/internal/lo/find.go b/internal/lo/find.go new file mode 100644 index 0000000..f3f8c83 --- /dev/null +++ b/internal/lo/find.go @@ -0,0 +1,372 @@ +package lo + +import ( + "fmt" + "math/rand" + + "golang.org/x/exp/constraints" +) + +// import "golang.org/x/exp/constraints" + +// IndexOf returns the index at which the first occurrence of a value is found in an array or return -1 +// if the value cannot be found. +func IndexOf[T comparable](collection []T, element T) int { + for i, item := range collection { + if item == element { + return i + } + } + + return -1 +} + +// LastIndexOf returns the index at which the last occurrence of a value is found in an array or return -1 +// if the value cannot be found. +func LastIndexOf[T comparable](collection []T, element T) int { + length := len(collection) + + for i := length - 1; i >= 0; i-- { + if collection[i] == element { + return i + } + } + + return -1 +} + +// Find search an element in a slice based on a predicate. It returns element and true if element was found. +func Find[T any](collection []T, predicate func(item T) bool) (T, bool) { + for _, item := range collection { + if predicate(item) { + return item, true + } + } + + var result T + return result, false +} + +// FindIndexOf searches an element in a slice based on a predicate and returns the index and true. +// It returns -1 and false if the element is not found. +func FindIndexOf[T any](collection []T, predicate func(item T) bool) (T, int, bool) { //nolint:gocritic // foo + for i, item := range collection { + if predicate(item) { + return item, i, true + } + } + + var result T + return result, -1, false +} + +// FindLastIndexOf searches last element in a slice based on a predicate and returns the index and true. +// It returns -1 and false if the element is not found. +func FindLastIndexOf[T any](collection []T, predicate func(item T) bool) (T, int, bool) { //nolint:gocritic // foo + length := len(collection) + + for i := length - 1; i >= 0; i-- { + if predicate(collection[i]) { + return collection[i], i, true + } + } + + var result T + return result, -1, false +} + +// FindOrElse search an element in a slice based on a predicate. It returns the element if found or a given fallback value otherwise. +func FindOrElse[T any](collection []T, fallback T, predicate func(item T) bool) T { + for _, item := range collection { + if predicate(item) { + return item + } + } + + return fallback +} + +// FindKey returns the key of the first value matching. +func FindKey[K comparable, V comparable](object map[K]V, value V) (K, bool) { + for k, v := range object { + if v == value { + return k, true + } + } + + return Empty[K](), false +} + +// FindKeyBy returns the key of the first element predicate returns truthy for. +func FindKeyBy[K comparable, V any](object map[K]V, predicate func(key K, value V) bool) (K, bool) { + for k, v := range object { + if predicate(k, v) { + return k, true + } + } + + return Empty[K](), false +} + +// FindUniques returns a slice with all the unique elements of the collection. +// The order of result values is determined by the order they occur in the collection. +func FindUniques[T comparable](collection []T) []T { + isDupl := make(map[T]bool, len(collection)) + + for _, item := range collection { + duplicated, ok := isDupl[item] + if !ok { + isDupl[item] = false + } else if !duplicated { + isDupl[item] = true + } + } + + result := make([]T, 0, len(collection)-len(isDupl)) + + for _, item := range collection { + if duplicated := isDupl[item]; !duplicated { + result = append(result, item) + } + } + + return result +} + +// FindUniquesBy returns a slice with all the unique elements of the collection. +// The order of result values is determined by the order they occur in the array. It accepts `iteratee` which is +// invoked for each element in array to generate the criterion by which uniqueness is computed. +func FindUniquesBy[T any, U comparable](collection []T, iteratee func(item T) U) []T { + isDupl := make(map[U]bool, len(collection)) + + for _, item := range collection { + key := iteratee(item) + + duplicated, ok := isDupl[key] + if !ok { + isDupl[key] = false + } else if !duplicated { + isDupl[key] = true + } + } + + result := make([]T, 0, len(collection)-len(isDupl)) + + for _, item := range collection { + key := iteratee(item) + + if duplicated := isDupl[key]; !duplicated { + result = append(result, item) + } + } + + return result +} + +// FindDuplicates returns a slice with the first occurrence of each duplicated elements of the collection. +// The order of result values is determined by the order they occur in the collection. +func FindDuplicates[T comparable](collection []T) []T { + isDupl := make(map[T]bool, len(collection)) + + for _, item := range collection { + duplicated, ok := isDupl[item] + if !ok { + isDupl[item] = false + } else if !duplicated { + isDupl[item] = true + } + } + + result := make([]T, 0, len(collection)-len(isDupl)) + + for _, item := range collection { + if duplicated := isDupl[item]; duplicated { + result = append(result, item) + isDupl[item] = false + } + } + + return result +} + +// FindDuplicatesBy returns a slice with the first occurrence of each duplicated elements of the collection. +// The order of result values is determined by the order they occur in the array. It accepts `iteratee` which is +// invoked for each element in array to generate the criterion by which uniqueness is computed. +func FindDuplicatesBy[T any, U comparable](collection []T, iteratee func(item T) U) []T { + isDupl := make(map[U]bool, len(collection)) + + for _, item := range collection { + key := iteratee(item) + + duplicated, ok := isDupl[key] + if !ok { + isDupl[key] = false + } else if !duplicated { + isDupl[key] = true + } + } + + result := make([]T, 0, len(collection)-len(isDupl)) + + for _, item := range collection { + key := iteratee(item) + + if duplicated := isDupl[key]; duplicated { + result = append(result, item) + isDupl[key] = false + } + } + + return result +} + +// Min search the minimum value of a collection. +// Returns zero value when collection is empty. +func Min[T constraints.Ordered](collection []T) T { + var min T + + if len(collection) == 0 { + return min + } + + min = collection[0] + + for i := 1; i < len(collection); i++ { + item := collection[i] + + if item < min { + min = item + } + } + + return min +} + +// MinBy search the minimum value of a collection using the given comparison function. +// If several values of the collection are equal to the smallest value, returns the first such value. +// Returns zero value when collection is empty. +func MinBy[T any](collection []T, comparison func(a T, b T) bool) T { + var min T + + if len(collection) == 0 { + return min + } + + min = collection[0] + + for i := 1; i < len(collection); i++ { + item := collection[i] + + if comparison(item, min) { + min = item + } + } + + return min +} + +// Max searches the maximum value of a collection. +// Returns zero value when collection is empty. +func Max[T constraints.Ordered](collection []T) T { + var max T + + if len(collection) == 0 { + return max + } + + max = collection[0] + + for i := 1; i < len(collection); i++ { + item := collection[i] + + if item > max { + max = item + } + } + + return max +} + +// MaxBy search the maximum value of a collection using the given comparison function. +// If several values of the collection are equal to the greatest value, returns the first such value. +// Returns zero value when collection is empty. +func MaxBy[T any](collection []T, comparison func(a T, b T) bool) T { + var max T + + if len(collection) == 0 { + return max + } + + max = collection[0] + + for i := 1; i < len(collection); i++ { + item := collection[i] + + if comparison(item, max) { + max = item + } + } + + return max +} + +// Last returns the last element of a collection or error if empty. +func Last[T any](collection []T) (T, error) { + length := len(collection) + + if length == 0 { + var t T + return t, fmt.Errorf("last: cannot extract the last element of an empty slice") + } + + return collection[length-1], nil +} + +// Nth returns the element at index `nth` of collection. If `nth` is negative, the nth element +// from the end is returned. An error is returned when nth is out of slice bounds. +func Nth[T any, N constraints.Integer](collection []T, nth N) (T, error) { + n := int(nth) + l := len(collection) + if n >= l || -n > l { + var t T + return t, fmt.Errorf("nth: %d out of slice bounds", n) + } + + if n >= 0 { + return collection[n], nil + } + return collection[l+n], nil +} + +// Sample returns a random item from collection. +func Sample[T any](collection []T) T { + size := len(collection) + if size == 0 { + return Empty[T]() + } + + return collection[rand.Intn(size)] //nolint:gosec // foo +} + +// Samples returns N random unique items from collection. +func Samples[T any](collection []T, count int) []T { + size := len(collection) + + cpy := append([]T{}, collection...) + + results := []T{} + + for i := 0; i < size && i < count; i++ { + copyLength := size - i + + index := rand.Intn(size - i) //nolint:gosec // foo + results = append(results, cpy[index]) + + // Removes element. + // It is faster to swap with last element and remove it. + cpy[index] = cpy[copyLength-1] + cpy = cpy[:copyLength-1] + } + + return results +} 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/type-manipulation.go b/internal/lo/type-manipulation.go new file mode 100644 index 0000000..b1e932e --- /dev/null +++ b/internal/lo/type-manipulation.go @@ -0,0 +1,108 @@ +package lo + +import "reflect" + +// ToPtr returns a pointer copy of value. +func ToPtr[T any](x T) *T { + return &x +} + +// IsNil checks if a value is nil or if it's a reference type with a nil underlying value. +func IsNil(x any) bool { + defer func() { recover() }() //nolint:errcheck // foo + return x == nil || reflect.ValueOf(x).IsNil() +} + +// EmptyableToPtr returns a pointer copy of value if it's nonzero. +// Otherwise, returns nil pointer. +func EmptyableToPtr[T any](x T) *T { + // ๐Ÿคฎ + isZero := reflect.ValueOf(&x).Elem().IsZero() + if isZero { + return nil + } + + return &x +} + +// FromPtr returns the pointer value or empty. +func FromPtr[T any](x *T) T { + if x == nil { + return Empty[T]() + } + + return *x +} + +// FromPtrOr returns the pointer value or the fallback value. +func FromPtrOr[T any](x *T, fallback T) T { + if x == nil { + return fallback + } + + return *x +} + +// ToSlicePtr returns a slice of pointer copy of value. +func ToSlicePtr[T any](collection []T) []*T { + return Map(collection, func(x T, _ int) *T { + return &x + }) +} + +// ToAnySlice returns a slice with all elements mapped to `any` type +func ToAnySlice[T any](collection []T) []any { + result := make([]any, len(collection)) + for i, item := range collection { + result[i] = item + } + return result +} + +// FromAnySlice returns an `any` slice with all elements mapped to a type. +// Returns false in case of type conversion failure. +func FromAnySlice[T any](in []any) (out []T, ok bool) { + defer func() { + if r := recover(); r != nil { + out = []T{} + ok = false + } + }() + + result := make([]T, len(in)) + for i, item := range in { + result[i] = item.(T) //nolint:errcheck // foo + } + return result, true +} + +// Empty returns an empty value. +func Empty[T any]() T { + var zero T + return zero +} + +// IsEmpty returns true if argument is a zero value. +func IsEmpty[T comparable](v T) bool { + var zero T + return zero == v +} + +// IsNotEmpty returns true if argument is not a zero value. +func IsNotEmpty[T comparable](v T) bool { + var zero T + return zero != v +} + +// Coalesce returns the first non-empty arguments. Arguments must be comparable. +func Coalesce[T comparable](v ...T) (result T, ok bool) { + for _, e := range v { + if e != result { + result = e + ok = true + return + } + } + + return +} 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/override/actions.go b/internal/override/actions.go new file mode 100644 index 0000000..21578e8 --- /dev/null +++ b/internal/override/actions.go @@ -0,0 +1,57 @@ +package override + +import ( + "github.com/snivilised/traverse/core" + "github.com/snivilised/traverse/measure" + "github.com/snivilised/traverse/tapable" +) + +// override package provides a similar function to tapable except we +// use the name action to replace hook. The difference between the +// two are that hooks allow for the client to customise core internal +// behaviour, where as an action 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 override; that is to say we need to avoid +// circular dependencies;... + +type ( + Action[F any] interface { + tapable.Invokable[F] + // Intercept overrides the default tap-able core function + Intercept(handler F) + } + + Actions struct { + HandleChildren Action[HandleChildrenInterceptor] + } + + // ActionCtrl contains the handler function to be invoked. The control + // is agnostic to the handler's signature and therefore can not invoke it. + ActionCtrl[F any] struct { + handler F + def F + } + + HandleChildrenInterceptor func( + inspection core.Inspection, + mums measure.MutableMetrics, + ) +) + +func NewActionCtrl[F any](handler F) *ActionCtrl[F] { + return &ActionCtrl[F]{ + handler: handler, + } +} + +// add life-cycle style broadcast + +func (c *ActionCtrl[F]) Intercept(handler F) { + c.handler = handler +} + +func (c *ActionCtrl[F]) Invoke() F { + return c.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..3598b0e 100644 --- a/internal/refine/filter-plugin.go +++ b/internal/refine/filter-plugin.go @@ -4,25 +4,33 @@ import ( "github.com/snivilised/traverse/core" "github.com/snivilised/traverse/enums" "github.com/snivilised/traverse/internal/kernel" + "github.com/snivilised/traverse/internal/lo" "github.com/snivilised/traverse/internal/types" + "github.com/snivilised/traverse/measure" "github.com/snivilised/traverse/pref" ) 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 + mums measure.MutableMetrics } func (p *Plugin) Name() string { @@ -32,24 +40,63 @@ 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) + } + 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 + if p.filters.Node == nil { + return true, nil + } + + matched := p.filters.Node.IsMatch(node) + + if !matched { + filteredOutMetric := lo.Ternary(node.IsFolder(), + enums.MetricNoFoldersFilteredOut, + enums.MetricNoFilesFilteredOut, + ) + p.mums[filteredOutMetric].Tick() + } + + return matched, nil } -func (p *Plugin) Init() error { - p.Mediator.Supervisor().Many( +func (p *Plugin) Init(pi *types.PluginInit) error { + // [KEEP-FILTER-IN-SYNC] keep this in sync with the default + // behaviour in builders.override.Actions + p.mums = p.Mediator.Supervisor().Many( enums.MetricNoFoldersFilteredOut, enums.MetricNoFilesFilteredOut, + enums.MetricNoChildFilesFilteredOut, + ) + + pi.Actions.HandleChildren.Intercept( + func(inspection core.Inspection, mums measure.MutableMetrics) { + files := inspection.Sort(enums.EntryTypeFile) + matching := p.filters.Children.Matching(files) + + inspection.AssignChildren(matching) + mums[enums.MetricNoChildFilesFound].Times(uint(len(files))) + + filteredOut := len(files) - len(matching) + p.mums[enums.MetricNoChildFilesFilteredOut].Times(uint(filteredOut)) + }, ) 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/resume/resume-plugin.go b/internal/resume/resume-plugin.go index 39879b8..d3507d3 100644 --- a/internal/resume/resume-plugin.go +++ b/internal/resume/resume-plugin.go @@ -36,7 +36,7 @@ func (p *Plugin) Role() enums.Role { return enums.RoleFastward } -func (p *Plugin) Init() error { +func (p *Plugin) Init(_ *types.PluginInit) error { return p.Mediator.Decorate(p) } diff --git a/internal/sampling/sampling-plugin.go b/internal/sampling/sampling-plugin.go index 39e8cdc..8c4bbee 100644 --- a/internal/sampling/sampling-plugin.go +++ b/internal/sampling/sampling-plugin.go @@ -42,6 +42,6 @@ func (p *Plugin) Next(node *core.Node) (bool, error) { return true, nil } -func (p *Plugin) Init() error { +func (p *Plugin) Init(_ *types.PluginInit) error { return p.Mediator.Decorate(p) } diff --git a/internal/types/definitions.go b/internal/types/definitions.go index 1e31d6c..d9d15be 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/override" "github.com/snivilised/traverse/measure" "github.com/snivilised/traverse/pref" ) @@ -44,6 +45,10 @@ type ( Arrange(roles []enums.Role) } + PluginInit struct { + Actions *override.Actions + } + // Mediator controls interactions between different entities of // of the navigator Mediator interface { @@ -62,6 +67,7 @@ type ( Resources struct { FS FileSystems Supervisor *measure.Supervisor + Actions *override.Actions } // Plugin used to define interaction with supplementary features @@ -69,7 +75,7 @@ type ( Name() string Register(kc KernelController) error Role() enums.Role - Init() error + Init(pi *PluginInit) error } // Restoration; tbd... @@ -93,6 +99,7 @@ type ( core.Navigator Ignite(ignition *Ignition) Result(ctx context.Context, err error) *KernelResult + Mediator() Mediator } ) @@ -137,3 +144,15 @@ func (r *KernelResult) Metrics() measure.Reporter { func (r *KernelResult) Error() error { return r.err } + +type ( + FilterChildren interface { // TODO: is this still needed? + 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/measure/measure-defs.go b/measure/measure-defs.go index 1176273..e27fd1f 100644 --- a/measure/measure-defs.go +++ b/measure/measure-defs.go @@ -18,10 +18,11 @@ type ( Value() MetricValue } - // Mutable represents write access to the metric - Mutable interface { + // MutableMetric represents write access to the metric + MutableMetric interface { Metric Tick() MetricValue + Times(increment uint) MetricValue } // Reporter represents query access to the metrics Supervisor @@ -48,3 +49,9 @@ func (m *BaseMetric) Tick() MetricValue { return m.counter } + +func (m *BaseMetric) Times(increment uint) MetricValue { + m.counter += increment + + return m.counter +} diff --git a/measure/supervisor.go b/measure/supervisor.go index 12ea8db..806b4e9 100644 --- a/measure/supervisor.go +++ b/measure/supervisor.go @@ -5,8 +5,8 @@ import ( ) type ( - Metrics map[enums.Metric]Metric - Mutables map[enums.Metric]Mutable + Metrics map[enums.Metric]Metric + MutableMetrics map[enums.Metric]MutableMetric Supervisor struct { metrics Metrics @@ -19,7 +19,7 @@ func New() *Supervisor { } } -func (s *Supervisor) Single(mt enums.Metric) Mutable { +func (s *Supervisor) Single(mt enums.Metric) MutableMetric { if _, exists := s.metrics[mt]; !exists { metric := &BaseMetric{ t: mt, @@ -29,11 +29,11 @@ func (s *Supervisor) Single(mt enums.Metric) Mutable { return metric } - return s.metrics[mt].(Mutable) + return s.metrics[mt].(MutableMetric) } -func (s *Supervisor) Many(metrics ...enums.Metric) Mutables { - result := make(Mutables) +func (s *Supervisor) Many(metrics ...enums.Metric) MutableMetrics { + result := make(MutableMetrics) for _, mt := range metrics { metric := &BaseMetric{ 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/support_test.go b/support_test.go deleted file mode 100644 index 4d6c10c..0000000 --- a/support_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package tv_test - -import ( - "errors" - - tv "github.com/snivilised/traverse" -) - -var ( - errBuildOptions = errors.New("options build error") -) - -const ( - RootPath = "traversal-root-path" - RestorePath = "/from-restore-path" - files = 3 - folders = 2 -) - -var noOpHandler = func(_ *tv.Node) error { - return nil -} diff --git a/tapable/tapable-defs.go b/tapable/tapable-defs.go index 89f8ee5..809a402 100644 --- a/tapable/tapable-defs.go +++ b/tapable/tapable-defs.go @@ -1,14 +1,17 @@ package tapable type ( + Invokable[F any] interface { + // Invoke returns the hook function for execution. + Invoke() F + } + Hook[F any] interface { + Invokable[F] // Tap overrides the default tap-able core function Tap(handler F) // Default returns the default function for this hook Default() F - - // Invoke returns the hook function for execution. - Invoke() F } ) diff --git a/traverse-api.go b/traverse-api.go index 2527a7c..8e4208e 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, override] +// override: [tapable], !("types") // --- // // ๐Ÿ”† support layer @@ -146,6 +148,7 @@ type Using = pref.Using // ๐Ÿ”† platform layer // core: [] // enums: [none] +// measure: [] // --- // ============================================================================ // diff --git a/traverse-suite_test.go b/traverse-suite_test.go index 31d67b8..1ea6ee4 100644 --- a/traverse-suite_test.go +++ b/traverse-suite_test.go @@ -1,13 +1,30 @@ package tv_test import ( + "errors" "testing" . "github.com/onsi/ginkgo/v2" //nolint:revive // ok . "github.com/onsi/gomega" //nolint:revive // ok + tv "github.com/snivilised/traverse" ) func TestTraverse(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Traverse Suite") } + +var ( + errBuildOptions = errors.New("options build error") +) + +const ( + RootPath = "traversal-root-path" + RestorePath = "/from-restore-path" + files = 3 + folders = 2 +) + +var noOpHandler = func(_ *tv.Node) error { + return nil +}