From 3d0ba994bccbd809142412f6c4c2d3a133a3489e Mon Sep 17 00:00:00 2001 From: plastikfan Date: Tue, 13 Aug 2024 11:06:24 +0100 Subject: [PATCH] feat: add extenio functionality (#3) --- .vscode/settings.json | 26 +- go.mod | 12 +- go.sum | 16 - i18n/i18n_suite_test.go | 13 - internal/helpers/test-utilities.go | 19 +- internal/lo/LICENSE | 21 + internal/lo/condition.go | 150 +++++ internal/lo/intersect.go | 184 ++++++ internal/lo/slice.go | 130 ++++ internal/lo/types.go | 123 ++++ .../default/li18ngo.active.en-GB.json | 0 .../deploy/li18ngo.active.en-US.json | 0 locale/locale-suite_test.go | 13 + locale/messages-command.go | 102 ++++ {i18n => locale}/messages-general.go | 4 +- i18n/messages-errors.go => locale/messages.go | 15 +- {i18n => locale}/out/active.en-GB.json | 0 {i18n => locale}/out/active.en-US.json | 0 .../out/en-US/li18ngo.translate.en-US.json | 0 {i18n => locale}/out/translate.en-US.json | 0 {i18n => locale}/test-i18n-messages_test.go | 4 +- {i18n => locale}/translate-defs.go | 4 +- matchers/exists-in-fs.go | 46 ++ storage/mem-fs.go | 120 ++++ storage/native-fs.go | 106 ++++ storage/storage-defs.go | 94 +++ storage/storage-suite_test.go | 13 + storage/virtual-fs_test.go | 439 ++++++++++++++ translate/i18n-messages_test.go | 50 ++ translate/localisable-error.go | 30 + translate/localisation-errors.go | 43 ++ translate/localizer-factory.go | 63 ++ translate/multiplexor-container.go | 36 ++ translate/translate-defs.go | 128 ++++ translate/translate-suite_test.go | 13 + translate/translator-factories.go | 63 ++ translate/translator.go | 196 +++++++ utils/ensure-path-at.go | 47 ++ utils/ensure-path-at_test.go | 80 +++ utils/exists.go | 38 ++ utils/exists_test.go | 73 +++ utils/is-nil.go | 22 + utils/is-nil_test.go | 57 ++ utils/member-property.go | 189 ++++++ utils/member-property_test.go | 553 ++++++++++++++++++ utils/must.go | 8 + utils/resolve-path.go | 83 +++ utils/resolve-path_test.go | 121 ++++ utils/spilt-parent.go | 12 + utils/utils_suite_test.go | 13 + 50 files changed, 3512 insertions(+), 60 deletions(-) delete mode 100644 i18n/i18n_suite_test.go create mode 100644 internal/lo/LICENSE create mode 100644 internal/lo/condition.go create mode 100644 internal/lo/intersect.go create mode 100644 internal/lo/slice.go create mode 100644 internal/lo/types.go rename {i18n => locale}/default/li18ngo.active.en-GB.json (100%) rename {i18n => locale}/deploy/li18ngo.active.en-US.json (100%) create mode 100644 locale/locale-suite_test.go create mode 100644 locale/messages-command.go rename {i18n => locale}/messages-general.go (85%) rename i18n/messages-errors.go => locale/messages.go (76%) rename {i18n => locale}/out/active.en-GB.json (100%) rename {i18n => locale}/out/active.en-US.json (100%) rename {i18n => locale}/out/en-US/li18ngo.translate.en-US.json (100%) rename {i18n => locale}/out/translate.en-US.json (100%) rename {i18n => locale}/test-i18n-messages_test.go (94%) rename {i18n => locale}/translate-defs.go (50%) create mode 100644 matchers/exists-in-fs.go create mode 100644 storage/mem-fs.go create mode 100644 storage/native-fs.go create mode 100644 storage/storage-defs.go create mode 100644 storage/storage-suite_test.go create mode 100644 storage/virtual-fs_test.go create mode 100644 translate/i18n-messages_test.go create mode 100644 translate/localisable-error.go create mode 100644 translate/localisation-errors.go create mode 100644 translate/localizer-factory.go create mode 100644 translate/multiplexor-container.go create mode 100644 translate/translate-defs.go create mode 100644 translate/translate-suite_test.go create mode 100644 translate/translator-factories.go create mode 100644 translate/translator.go create mode 100644 utils/ensure-path-at.go create mode 100644 utils/ensure-path-at_test.go create mode 100644 utils/exists.go create mode 100644 utils/exists_test.go create mode 100644 utils/is-nil.go create mode 100644 utils/is-nil_test.go create mode 100644 utils/member-property.go create mode 100644 utils/member-property_test.go create mode 100644 utils/must.go create mode 100644 utils/resolve-path.go create mode 100644 utils/resolve-path_test.go create mode 100644 utils/spilt-parent.go create mode 100644 utils/utils_suite_test.go diff --git a/.vscode/settings.json b/.vscode/settings.json index 8da9619..cf2c9af 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,10 +4,11 @@ "--fast" ], "cSpell.words": [ - "astrolib", + "avfs", + "beezledub", "bodyclose", "cmds", - "coverpkg", + "cobrass", "coverprofile", "cubiest", "deadcode", @@ -18,7 +19,9 @@ "errcheck", "exportloopref", "extendio", + "faydeaudeau", "fieldalignment", + "GOARCH", "goconst", "gocritic", "gocyclo", @@ -37,27 +40,40 @@ "jibberjabber", "linters", "mattn", + "memfs", "nakedret", + "newname", + "newpath", + "nicksnyder", "nolint", "nolintlint", + "nosec", + "oldname", + "oldpath", "onsi", "outdir", - "pixa", "prealloc", + "rabbitweed", "repotoken", + "samber", "shogo", "sidewalk", + "snivilised", "staticcheck", + "struct", "structcheck", "stylecheck", + "Taskfile", "thelper", + "toplevel", "tparallel", "typecheck", "unconvert", "unparam", "varcheck", "watchv", - "watchvc", - "watchvi" + "Wrapf", + "xpander", + "zeroable" ] } diff --git a/go.mod b/go.mod index b95d317..67028fa 100644 --- a/go.mod +++ b/go.mod @@ -3,25 +3,25 @@ module github.com/snivilised/li18ngo go 1.22.0 require ( + github.com/avfs/avfs v0.33.0 + github.com/nicksnyder/go-i18n/v2 v2.4.0 github.com/onsi/ginkgo/v2 v2.20.0 github.com/onsi/gomega v1.34.1 - github.com/snivilised/extendio v0.7.0 + github.com/pkg/errors v0.9.1 + golang.org/x/text v0.17.0 ) require ( - github.com/avfs/avfs v0.33.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect github.com/kr/pretty v0.3.1 // indirect - github.com/nicksnyder/go-i18n/v2 v2.4.0 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/samber/lo v1.39.0 // indirect + github.com/rogpeppe/go-internal v1.11.0 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/net v0.28.0 // indirect golang.org/x/sys v0.23.0 // indirect - golang.org/x/text v0.17.0 // indirect golang.org/x/tools v0.24.0 // indirect + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 2febf8b..94abe65 100644 --- a/go.sum +++ b/go.sum @@ -13,14 +13,10 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k= github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/nicksnyder/go-i18n/v2 v2.4.0 h1:3IcvPOAvnCKwNm0TB0dLDTuawWEj+ax/RERNC+diLMM= github.com/nicksnyder/go-i18n/v2 v2.4.0/go.mod h1:nxYSZE9M0bf3Y70gPQjN9ha7XNHX7gMc814+6wVyEI4= github.com/onsi/ginkgo/v2 v2.20.0 h1:PE84V2mHqoT1sglvHc8ZdQtPcwmvvt29WLEEO3xmdZw= @@ -35,20 +31,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= -github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA= -github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= -github.com/snivilised/extendio v0.7.0 h1:MY6w9qCK5wVEvP2WpMT5ywJwpDJe97WHDGuwrsTLpek= -github.com/snivilised/extendio v0.7.0/go.mod h1:l8MwJOy9ojMQYJrSKRbQS3WfDylevnRtBp/zwAmFEKc= -github.com/snivilised/lorax v0.5.2 h1:iReIJl63tydiPSSD0YzsNQFX1CctmvMkYx0aSxoZJKo= -github.com/snivilised/lorax v0.5.2/go.mod h1:7H1JPgSn4h4p8NSqfl64raacYefdm/FiFkfcZ51PVHY= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.uber.org/zap/exp v0.2.0 h1:FtGenNNeCATRB3CmB/yEUnjEFeJWpB/pMcy7e2bKPYs= -go.uber.org/zap/exp v0.2.0/go.mod h1:t0gqAIdh1MfKv9EwN/dLwfZnJxe9ITAZN78HEWPFWDQ= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= diff --git a/i18n/i18n_suite_test.go b/i18n/i18n_suite_test.go deleted file mode 100644 index dac047d..0000000 --- a/i18n/i18n_suite_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package i18n_test - -import ( - "testing" - - . "github.com/onsi/ginkgo/v2" //nolint:revive // ginkgo ok - . "github.com/onsi/gomega" //nolint:revive // gomega ok -) - -func TestI18n(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "I18n Suite") -} diff --git a/internal/helpers/test-utilities.go b/internal/helpers/test-utilities.go index 2111a9f..b492029 100644 --- a/internal/helpers/test-utilities.go +++ b/internal/helpers/test-utilities.go @@ -1,18 +1,21 @@ package helpers import ( + "errors" "fmt" "os" + "os/exec" "path/filepath" - "runtime" "strings" ) +// Path; the relative path always uses / func Path(parent, relative string) string { segments := strings.Split(relative, "/") return filepath.Join(append([]string{parent}, segments...)...) } +// Normalise; the relative path always uses / func Normalise(p string) string { return strings.ReplaceAll(p, "/", string(filepath.Separator)) } @@ -42,19 +45,23 @@ func Root() string { panic("could not get root path") } +// Repo; the relative path always uses / func Repo(relative string) string { - _, filename, _, _ := runtime.Caller(0) //nolint:dogsled // ignore - return Path(filepath.Dir(filename), relative) + cmd := exec.Command("git", "rev-parse", "--show-toplevel") + output, _ := cmd.Output() + repo := strings.TrimSpace(string(output)) + + return Path(repo, relative) } -func Log() string { +func Log() (string, error) { if current, err := os.Getwd(); err == nil { parent, _ := filepath.Split(current) grand := filepath.Dir(parent) great := filepath.Dir(grand) - return filepath.Join(great, "Test", "test.log") + return filepath.Join(great, "Test", "test.log"), nil } - panic("could not get root path") + return "", errors.New("could not get path") } diff --git a/internal/lo/LICENSE b/internal/lo/LICENSE new file mode 100644 index 0000000..c3dc72d --- /dev/null +++ b/internal/lo/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Samuel Berthe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/internal/lo/condition.go b/internal/lo/condition.go new file mode 100644 index 0000000..5ec9157 --- /dev/null +++ b/internal/lo/condition.go @@ -0,0 +1,150 @@ +package lo + +// Ternary is a 1 line if/else statement. +// Play: https://go.dev/play/p/t-D7WBL44h2 +func Ternary[T any](condition bool, ifOutput, elseOutput T) T { + if condition { + return ifOutput + } + + return elseOutput +} + +// TernaryF is a 1 line if/else statement whose options are functions +// Play: https://go.dev/play/p/AO4VW20JoqM +func TernaryF[T any](condition bool, ifFunc, elseFunc func() T) T { + if condition { + return ifFunc() + } + + return elseFunc() +} + +// type IfElse[T any] struct { +// result T +// done bool +// } + +// If +// Play: https://go.dev/play/p/WSw3ApMxhyW +// func If[T any](condition bool, result T) *IfElse[T] { +// if condition { +// return &IfElse[T]{result, true} +// } + +// var t T +// return &IfElse[T]{t, false} +// } + +// IfF +// Play: https://go.dev/play/p/WSw3ApMxhyW +// func IfF[T any](condition bool, resultF func() T) *IfElse[T] { +// if condition { +// return &IfElse[T]{resultF(), true} +// } + +// var t T +// return &IfElse[T]{t, false} +// } + +// ElseIf +// Play: https://go.dev/play/p/WSw3ApMxhyW +// func (i *IfElse[T]) ElseIf(condition bool, result T) *IfElse[T] { +// if !i.done && condition { +// i.result = result +// i.done = true +// } + +// return i +// } + +// ElseIfF +// Play: https://go.dev/play/p/WSw3ApMxhyW +// func (i *IfElse[T]) ElseIfF(condition bool, resultF func() T) *IfElse[T] { +// if !i.done && condition { +// i.result = resultF() +// i.done = true +// } + +// return i +// } + +// Else +// Play: https://go.dev/play/p/WSw3ApMxhyW +// func (i *IfElse[T]) Else(result T) T { +// if i.done { +// return i.result +// } + +// return result +// } + +// ElseF +// Play: https://go.dev/play/p/WSw3ApMxhyW +// func (i *IfElse[T]) ElseF(resultF func() T) T { +// if i.done { +// return i.result +// } + +// return resultF() +// } + +// type SwitchCase[T comparable, R any] struct { +// predicate T +// result R +// done bool +// } + +// Switch is a pure functional switch/case/default statement. +// Play: https://go.dev/play/p/TGbKUMAeRUd +// func Switch[T comparable, R any](predicate T) *SwitchCase[T, R] { +// var result R + +// return &SwitchCase[T, R]{ +// predicate, +// result, +// false, +// } +// } + +// Case +// Play: https://go.dev/play/p/TGbKUMAeRUd +// func (s *SwitchCase[T, R]) Case(val T, result R) *SwitchCase[T, R] { +// if !s.done && s.predicate == val { +// s.result = result +// s.done = true +// } + +// return s +// } + +// CaseF +// Play: https://go.dev/play/p/TGbKUMAeRUd +// func (s *SwitchCase[T, R]) CaseF(val T, cb func() R) *SwitchCase[T, R] { +// if !s.done && s.predicate == val { +// s.result = cb() +// s.done = true +// } + +// return s +// } + +// Default +// Play: https://go.dev/play/p/TGbKUMAeRUd +// func (s *SwitchCase[T, R]) Default(result R) R { +// if !s.done { +// s.result = result +// } + +// return s.result +// } + +// DefaultF +// Play: https://go.dev/play/p/TGbKUMAeRUd +// func (s *SwitchCase[T, R]) DefaultF(cb func() R) R { +// if !s.done { +// s.result = cb() +// } + +// return s.result +// } diff --git a/internal/lo/intersect.go b/internal/lo/intersect.go new file mode 100644 index 0000000..234f004 --- /dev/null +++ b/internal/lo/intersect.go @@ -0,0 +1,184 @@ +package lo + +// Contains returns true if an element is present in a collection. +// func Contains[T comparable](collection []T, element T) bool { +// for i := range collection { +// if collection[i] == element { +// return true +// } +// } + +// return false +// } + +// ContainsBy returns true if predicate function return true. +func ContainsBy[T any](collection []T, predicate func(item T) bool) bool { + for i := range collection { + if predicate(collection[i]) { + return true + } + } + + return false +} + +// Every returns true if all elements of a subset are contained into a collection or if the subset is empty. +// func Every[T comparable](collection, subset []T) bool { +// for i := range subset { +// if !Contains(collection, subset[i]) { +// return false +// } +// } + +// return true +// } + +// EveryBy returns true if the predicate returns true for all of the elements in the collection or if the collection is empty. +// func EveryBy[T any](collection []T, predicate func(item T) bool) bool { +// for i := range collection { +// if !predicate(collection[i]) { +// return false +// } +// } + +// return true +// } + +// Some returns true if at least 1 element of a subset is contained into a collection. +// If the subset is empty Some returns false. +// func Some[T comparable](collection, subset []T) bool { +// for i := range subset { +// if Contains(collection, subset[i]) { +// return true +// } +// } + +// return false +// } + +// SomeBy returns true if the predicate returns true for any of the elements in the collection. +// If the collection is empty SomeBy returns false. +// func SomeBy[T any](collection []T, predicate func(item T) bool) bool { +// for i := range collection { +// if predicate(collection[i]) { +// return true +// } +// } + +// return false +// } + +// None returns true if no element of a subset are contained into a collection or if the subset is empty. +// func None[T comparable](collection []T, subset []T) bool { +// for i := range subset { +// if Contains(collection, subset[i]) { +// return false +// } +// } + +// return true +// } + +// NoneBy returns true if the predicate returns true for none of the elements in the collection or if the collection is empty. +// func NoneBy[T any](collection []T, predicate func(item T) bool) bool { +// for i := range collection { +// if predicate(collection[i]) { +// return false +// } +// } + +// return true +// } + +// Intersect returns the intersection between two collections. +// func Intersect[T comparable, Slice ~[]T](list1 Slice, list2 Slice) Slice { +// result := Slice{} +// seen := map[T]struct{}{} + +// for i := range list1 { +// seen[list1[i]] = struct{}{} +// } + +// for i := range list2 { +// if _, ok := seen[list2[i]]; ok { +// result = append(result, list2[i]) +// } +// } + +// return result +// } + +// Difference returns the difference between two collections. +// The first value is the collection of element absent of list2. +// The second value is the collection of element absent of list1. +// func Difference[T comparable, Slice ~[]T](list1 Slice, list2 Slice) (Slice, Slice) { +// left := Slice{} +// right := Slice{} + +// seenLeft := map[T]struct{}{} +// seenRight := map[T]struct{}{} + +// for i := range list1 { +// seenLeft[list1[i]] = struct{}{} +// } + +// for i := range list2 { +// seenRight[list2[i]] = struct{}{} +// } + +// for i := range list1 { +// if _, ok := seenRight[list1[i]]; !ok { +// left = append(left, list1[i]) +// } +// } + +// for i := range list2 { +// if _, ok := seenLeft[list2[i]]; !ok { +// right = append(right, list2[i]) +// } +// } + +// return left, right +// } + +// Union returns all distinct elements from given collections. +// result returns will not change the order of elements relatively. +// func Union[T comparable, Slice ~[]T](lists ...Slice) Slice { +// var capLen int + +// for _, list := range lists { +// capLen += len(list) +// } + +// result := make(Slice, 0, capLen) +// seen := make(map[T]struct{}, capLen) + +// for i := range lists { +// for j := range lists[i] { +// if _, ok := seen[lists[i][j]]; !ok { +// seen[lists[i][j]] = struct{}{} +// result = append(result, lists[i][j]) +// } +// } +// } + +// return result +// } + +// Without returns slice excluding all given values. +// func Without[T comparable, Slice ~[]T](collection Slice, exclude ...T) Slice { +// result := make(Slice, 0, len(collection)) +// for i := range collection { +// if !Contains(exclude, collection[i]) { +// result = append(result, collection[i]) +// } +// } +// return result +// } + +// WithoutEmpty returns slice excluding empty values. +// +// Deprecated: Use lo.Compact instead. +// func WithoutEmpty[T comparable, Slice ~[]T](collection Slice) Slice { +// return Compact(collection) +// } diff --git a/internal/lo/slice.go b/internal/lo/slice.go new file mode 100644 index 0000000..228f16b --- /dev/null +++ b/internal/lo/slice.go @@ -0,0 +1,130 @@ +package lo + +import "sync" + +// Map manipulates a slice and transforms it to a slice of another type. +// `iteratee` is call in parallel. Result keep the same order. +func Map[T any, R any](collection []T, iteratee func(item T, index int) R) []R { + result := make([]R, len(collection)) + + var wg sync.WaitGroup + wg.Add(len(collection)) + + for i, item := range collection { + go func(_item T, _i int) { + res := iteratee(_item, _i) + + result[_i] = res + + wg.Done() + }(item, i) + } + + wg.Wait() + + return result +} + +// ForEach iterates over elements of collection and invokes iteratee for each element. +// `iteratee` is call in parallel. +// func ForEach[T any](collection []T, iteratee func(item T, index int)) { +// var wg sync.WaitGroup +// wg.Add(len(collection)) + +// for i, item := range collection { +// go func(_item T, _i int) { +// iteratee(_item, _i) +// wg.Done() +// }(item, i) +// } + +// wg.Wait() +// } + +// Times invokes the iteratee n times, returning an array of the results of each invocation. +// The iteratee is invoked with index as argument. +// `iteratee` is call in parallel. +// func Times[T any](count int, iteratee func(index int) T) []T { +// result := make([]T, count) + +// var wg sync.WaitGroup +// wg.Add(count) + +// for i := 0; i < count; i++ { +// go func(_i int) { +// item := iteratee(_i) + +// result[_i] = item + +// wg.Done() +// }(i) +// } + +// wg.Wait() + +// return result +// } + +// GroupBy returns an object composed of keys generated from the results of running each element of collection through iteratee. +// `iteratee` is call in parallel. +// func GroupBy[T any, U comparable, Slice ~[]T](collection Slice, iteratee func(item T) U) map[U]Slice { +// result := map[U]Slice{} + +// var mu sync.Mutex +// var wg sync.WaitGroup +// wg.Add(len(collection)) + +// for _, item := range collection { +// go func(_item T) { +// key := iteratee(_item) + +// mu.Lock() + +// result[key] = append(result[key], _item) + +// mu.Unlock() +// wg.Done() +// }(item) +// } + +// wg.Wait() + +// return result +// } + +// PartitionBy returns an array of elements split into groups. The order of grouped values is +// determined by the order they occur in collection. The grouping is generated from the results +// of running each element of collection through iteratee. +// `iteratee` is call in parallel. +// func PartitionBy[T any, K comparable, Slice ~[]T](collection Slice, iteratee func(item T) K) []Slice { +// result := []Slice{} +// seen := map[K]int{} + +// var mu sync.Mutex +// var wg sync.WaitGroup +// wg.Add(len(collection)) + +// for _, item := range collection { +// go func(_item T) { +// key := iteratee(_item) + +// mu.Lock() + +// resultIndex, ok := seen[key] +// if !ok { +// resultIndex = len(result) +// seen[key] = resultIndex +// result = append(result, []T{}) +// } + +// result[resultIndex] = append(result[resultIndex], _item) + +// mu.Unlock() +// wg.Done() +// }(item) +// } + +// wg.Wait() + +// return result +// } diff --git a/internal/lo/types.go b/internal/lo/types.go new file mode 100644 index 0000000..f7a02bc --- /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/i18n/default/li18ngo.active.en-GB.json b/locale/default/li18ngo.active.en-GB.json similarity index 100% rename from i18n/default/li18ngo.active.en-GB.json rename to locale/default/li18ngo.active.en-GB.json diff --git a/i18n/deploy/li18ngo.active.en-US.json b/locale/deploy/li18ngo.active.en-US.json similarity index 100% rename from i18n/deploy/li18ngo.active.en-US.json rename to locale/deploy/li18ngo.active.en-US.json diff --git a/locale/locale-suite_test.go b/locale/locale-suite_test.go new file mode 100644 index 0000000..a0e997b --- /dev/null +++ b/locale/locale-suite_test.go @@ -0,0 +1,13 @@ +package locale_test + +import ( + "testing" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +func TestI18n(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Locale Suite") +} diff --git a/locale/messages-command.go b/locale/messages-command.go new file mode 100644 index 0000000..8301e1e --- /dev/null +++ b/locale/messages-command.go @@ -0,0 +1,102 @@ +package locale + +import ( + "github.com/nicksnyder/go-i18n/v2/i18n" +) + +// ๐ŸงŠ Root Cmd Short Description + +// RootCmdShortDescTemplData +type RootCmdShortDescTemplData struct { + li18ngoTemplData +} + +func (td RootCmdShortDescTemplData) Message() *i18n.Message { + return &i18n.Message{ + ID: "root-command-short-description", + Description: "short description for the root command", + Other: "A brief description of your application", + } +} + +// ๐ŸงŠ Root Cmd Long Description + +// RootCmdLongDescTemplData +type RootCmdLongDescTemplData struct { + li18ngoTemplData +} + +func (td RootCmdLongDescTemplData) Message() *i18n.Message { + return &i18n.Message{ + ID: "root-command-long-description", + Description: "long description for the root command", + Other: `A longer description that spans multiple lines and likely contains + examples and usage of using your application. For example: + + Cobra is a CLI library for Go that empowers applications. + This application is a tool to generate the needed files + to quickly create a Cobra application.`, + } +} + +// ๐ŸงŠ Root Cmd Config File Usage + +// / RootCmdConfigFileUsageTemplData +type RootCmdConfigFileUsageTemplData struct { + li18ngoTemplData + ConfigFileName string +} + +func (td RootCmdConfigFileUsageTemplData) Message() *i18n.Message { + return &i18n.Message{ + ID: "root-command-config-file-usage", + Description: "root command config flag usage", + Other: "config file (default is $HOME/{{.ConfigFileName}}.yml)", + } +} + +// ๐ŸงŠ Root Cmd Lang Usage + +// RootCmdLangUsageTemplData +type RootCmdLangUsageTemplData struct { + li18ngoTemplData +} + +func (td RootCmdLangUsageTemplData) Message() *i18n.Message { + return &i18n.Message{ + ID: "root-command-language-usage", + Description: "root command lang usage", + Other: "'lang' defines the language according to IETF BCP 47", + } +} + +// ๐ŸงŠ Widget Cmd Short Description + +// WidgetCmdShortDescTemplData +type WidgetCmdShortDescTemplData struct { + li18ngoTemplData +} + +func (td WidgetCmdShortDescTemplData) Message() *i18n.Message { + return &i18n.Message{ + ID: "widget-command-short-description", + Description: "short description for the widget command", + Other: "A brief description of widget command", + } +} + +// ๐ŸงŠ Widget Cmd Long Description + +// WidgetCmdLongDescTemplData +type WidgetCmdLongDescTemplData struct { + li18ngoTemplData +} + +func (td WidgetCmdLongDescTemplData) Message() *i18n.Message { + return &i18n.Message{ + ID: "widget-command-long-description", + Description: "long description for the widget command", + Other: `A longer description that spans multiple lines and likely contains + examples and usage of using your application.`, + } +} diff --git a/i18n/messages-general.go b/locale/messages-general.go similarity index 85% rename from i18n/messages-general.go rename to locale/messages-general.go index 7b8ce94..38bb38a 100644 --- a/i18n/messages-general.go +++ b/locale/messages-general.go @@ -1,7 +1,7 @@ -package i18n +package locale import ( - "github.com/snivilised/extendio/i18n" + "github.com/nicksnyder/go-i18n/v2/i18n" ) type UsingConfigFileTemplData struct { diff --git a/i18n/messages-errors.go b/locale/messages.go similarity index 76% rename from i18n/messages-errors.go rename to locale/messages.go index ad3e266..b59ec66 100644 --- a/i18n/messages-errors.go +++ b/locale/messages.go @@ -1,14 +1,15 @@ -package i18n +package locale import ( - "github.com/snivilised/extendio/i18n" + "github.com/nicksnyder/go-i18n/v2/i18n" + "github.com/snivilised/li18ngo/translate" ) // โŒ FooBar // FooBarTemplData - TODO: this is a none existent error that should be // replaced by the client. Its just defined here to illustrate the pattern -// that should be used to implement i18n with extendio. Also note, +// that should be used to implement i18n with li18ngo. Also note, // that this message has been removed from the translation files, so // it is not useable at run time. type FooBarTemplData struct { @@ -17,11 +18,11 @@ type FooBarTemplData struct { Reason error } -// the ID should use spp/library specific code, so replace astrolib with the +// the ID should use spp/library specific code, so replace li18ngo with the // name of the library implementing this template project. func (td FooBarTemplData) Message() *i18n.Message { return &i18n.Message{ - ID: "foo-bar.astrolib.nav", + ID: "foo-bar.li18ngo.nav", Description: "Foo Bar description", Other: "foo bar failure '{{.Path}}' (reason: {{.Reason}})", } @@ -34,7 +35,7 @@ type FooBarErrorBehaviourQuery interface { } type FooBarError struct { - i18n.LocalisableError + translate.LocalisableError } // FooBar enables the client to check if error is FooBarError @@ -46,7 +47,7 @@ func (e FooBarError) FooBar() bool { // NewFooBarError creates a FooBarError func NewFooBarError(path string, reason error) FooBarError { return FooBarError{ - LocalisableError: i18n.LocalisableError{ + LocalisableError: translate.LocalisableError{ Data: FooBarTemplData{ Path: path, Reason: reason, diff --git a/i18n/out/active.en-GB.json b/locale/out/active.en-GB.json similarity index 100% rename from i18n/out/active.en-GB.json rename to locale/out/active.en-GB.json diff --git a/i18n/out/active.en-US.json b/locale/out/active.en-US.json similarity index 100% rename from i18n/out/active.en-US.json rename to locale/out/active.en-US.json diff --git a/i18n/out/en-US/li18ngo.translate.en-US.json b/locale/out/en-US/li18ngo.translate.en-US.json similarity index 100% rename from i18n/out/en-US/li18ngo.translate.en-US.json rename to locale/out/en-US/li18ngo.translate.en-US.json diff --git a/i18n/out/translate.en-US.json b/locale/out/translate.en-US.json similarity index 100% rename from i18n/out/translate.en-US.json rename to locale/out/translate.en-US.json diff --git a/i18n/test-i18n-messages_test.go b/locale/test-i18n-messages_test.go similarity index 94% rename from i18n/test-i18n-messages_test.go rename to locale/test-i18n-messages_test.go index f00a22d..d12c076 100644 --- a/i18n/test-i18n-messages_test.go +++ b/locale/test-i18n-messages_test.go @@ -1,7 +1,7 @@ -package i18n_test +package locale_test import ( - "github.com/snivilised/extendio/i18n" + "github.com/nicksnyder/go-i18n/v2/i18n" ) const ( diff --git a/i18n/translate-defs.go b/locale/translate-defs.go similarity index 50% rename from i18n/translate-defs.go rename to locale/translate-defs.go index 590185c..2275949 100644 --- a/i18n/translate-defs.go +++ b/locale/translate-defs.go @@ -1,7 +1,5 @@ -package i18n +package locale -// CLIENT-TODO: Should be updated to use url of the implementing project, -// so should not be left as astrolib. (this should be set by auto-check) const Li18ngoSourceID = "github.com/snivilised/li18ngo" type li18ngoTemplData struct{} diff --git a/matchers/exists-in-fs.go b/matchers/exists-in-fs.go new file mode 100644 index 0000000..d54e556 --- /dev/null +++ b/matchers/exists-in-fs.go @@ -0,0 +1,46 @@ +package matchers + +import ( + "fmt" + + "github.com/onsi/gomega/types" + "github.com/snivilised/li18ngo/storage" +) + +type PathExistsMatcher struct { + vfs interface{} +} + +type AsDirectory string +type AsFile string + +func ExistInFS(fs interface{}) types.GomegaMatcher { + return &PathExistsMatcher{ + vfs: fs, + } +} + +func (m *PathExistsMatcher) Match(actual interface{}) (bool, error) { + vfs, fileSystemOK := m.vfs.(storage.VirtualFS) + if !fileSystemOK { + return false, fmt.Errorf("โŒ matcher expected a VirtualFS instance (%T)", vfs) + } + + if actualPath, dirOK := actual.(AsDirectory); dirOK { + return vfs.DirectoryExists(string(actualPath)), nil + } + + if actualPath, fileOK := actual.(AsFile); fileOK { + return vfs.FileExists(string(actualPath)), nil + } + + return false, fmt.Errorf("โŒ matcher expected an AsDirectory or AsFile instance (%T)", actual) +} + +func (m *PathExistsMatcher) FailureMessage(actual interface{}) string { + return fmt.Sprintf("๐Ÿ”ฅ Expected\n\t%v\npath to exist", actual) +} + +func (m *PathExistsMatcher) NegatedFailureMessage(actual interface{}) string { + return fmt.Sprintf("๐Ÿ”ฅ Expected\n\t%v\npath NOT to exist\n", actual) +} diff --git a/storage/mem-fs.go b/storage/mem-fs.go new file mode 100644 index 0000000..8c61c7a --- /dev/null +++ b/storage/mem-fs.go @@ -0,0 +1,120 @@ +package storage + +import ( + "fmt" + "io/fs" + "os" + + "github.com/avfs/avfs/vfs/memfs" + "github.com/pkg/errors" +) + +type memFS struct { + backend VirtualBackend + mfs *memfs.MemFS +} + +func UseMemFS() VirtualFS { + return &memFS{ + backend: "mem", + mfs: memfs.New(), + } +} + +func (ms *memFS) Backend() VirtualBackend { + return ms.backend +} + +// interface ExistsInFS + +func (ms *memFS) FileExists(path string) bool { + result := false + if info, err := ms.mfs.Lstat(path); err == nil { + result = !info.IsDir() + } + + return result +} + +func (ms *memFS) DirectoryExists(path string) bool { + result := false + if info, err := ms.mfs.Lstat(path); err == nil { + result = info.IsDir() + } + + return result +} + +// end: interface ExistsInFS + +// interface ReadOnlyVirtualFS + +func (ms *memFS) Lstat(path string) (fs.FileInfo, error) { + return ms.mfs.Lstat(path) +} + +func (ms *memFS) Stat(path string) (fs.FileInfo, error) { + return ms.mfs.Stat(path) +} + +func (ms *memFS) ReadFile(name string) ([]byte, error) { + return ms.mfs.ReadFile(name) +} + +func (ms *memFS) ReadDir(name string) ([]os.DirEntry, error) { + return ms.mfs.ReadDir(name) +} + +// end: interface ReadOnlyVirtualFS + +// interface WriteToFS + +func (ms *memFS) Chmod(name string, mode os.FileMode) error { + return ms.mfs.Chmod(name, mode) +} + +func (ms *memFS) Chown(name string, uid, gid int) error { + return ms.mfs.Chown(name, uid, gid) +} + +func (ms *memFS) Create(name string) (*os.File, error) { + f, err := ms.mfs.Create(name) + + if file, ok := f.(*os.File); ok { + return file, err + } + + return nil, errors.Wrap(err, + fmt.Sprintf("file '%v' creation in '%v' failed", name, ms.backend), + ) +} + +func (ms *memFS) Link(oldname, newname string) error { + return ms.mfs.Link(oldname, newname) +} + +func (ms *memFS) Mkdir(name string, perm fs.FileMode) error { + return ms.mfs.Mkdir(name, perm) +} + +func (ms *memFS) MkdirAll(path string, perm os.FileMode) error { + return ms.mfs.MkdirAll(path, perm) +} + +func (ms *memFS) Remove(name string) error { + return ms.mfs.Remove(name) +} + +func (ms *memFS) RemoveAll(path string) error { + return ms.mfs.RemoveAll(path) +} + +func (ms *memFS) Rename(oldpath, newpath string) error { + return ms.mfs.Rename(oldpath, newpath) +} + +func (ms *memFS) WriteFile(name string, data []byte, perm os.FileMode) error { + return ms.mfs.WriteFile(name, data, perm) +} + +// end: interface WriteToFS diff --git a/storage/native-fs.go b/storage/native-fs.go new file mode 100644 index 0000000..95ae3ee --- /dev/null +++ b/storage/native-fs.go @@ -0,0 +1,106 @@ +package storage + +import ( + "io/fs" + "os" +) + +type nativeFS struct { + backend VirtualBackend +} + +func UseNativeFS() VirtualFS { + return &nativeFS{ + backend: "native", + } +} + +func (ns *nativeFS) Backend() VirtualBackend { + return ns.backend +} + +// interface ExistsInFS + +func (ns *nativeFS) FileExists(path string) bool { + result := false + if info, err := os.Lstat(path); err == nil { + result = !info.IsDir() + } + + return result +} + +func (ns *nativeFS) DirectoryExists(path string) bool { + result := false + if info, err := os.Lstat(path); err == nil { + result = info.IsDir() + } + + return result +} + +// end: interface ExistsInFS + +// interface ReadOnlyVirtualFS + +func (ns *nativeFS) Lstat(path string) (fs.FileInfo, error) { + return os.Lstat(path) +} + +func (ns *nativeFS) Stat(path string) (fs.FileInfo, error) { + return os.Stat(path) +} + +func (ns *nativeFS) ReadFile(name string) ([]byte, error) { + return os.ReadFile(name) +} + +func (ns *nativeFS) ReadDir(name string) ([]os.DirEntry, error) { + return os.ReadDir(name) +} + +// end: interface ReadOnlyVirtualFS + +func (ns *nativeFS) Chmod(name string, mode os.FileMode) error { + return os.Chmod(name, mode) +} + +func (ns *nativeFS) Chown(name string, uid, gid int) error { + return os.Chown(name, uid, gid) +} + +func (ns *nativeFS) Create(name string) (*os.File, error) { + return os.Create(name) +} + +// interface WriteToFS + +func (ns *nativeFS) Link(oldname, newname string) error { + return os.Link(oldname, newname) +} + +func (ns *nativeFS) Mkdir(name string, perm fs.FileMode) error { + return os.Mkdir(name, perm) +} + +func (ns *nativeFS) MkdirAll(path string, perm os.FileMode) error { + return os.MkdirAll(path, perm) +} + +func (ns *nativeFS) Remove(name string) error { + return os.Remove(name) +} + +func (ns *nativeFS) RemoveAll(path string) error { + return os.RemoveAll(path) +} + +func (ns *nativeFS) Rename(oldpath, newpath string) error { + return os.Rename(oldpath, newpath) +} + +func (ns *nativeFS) WriteFile(name string, data []byte, perm os.FileMode) error { + return os.WriteFile(name, data, perm) +} + +// end: interface WriteToFS diff --git a/storage/storage-defs.go b/storage/storage-defs.go new file mode 100644 index 0000000..913450a --- /dev/null +++ b/storage/storage-defs.go @@ -0,0 +1,94 @@ +package storage + +import ( + "io/fs" + "os" +) + +type filepathAPI interface { + // Intended only for those filepath methods that actually affect the + // filesystem. Eg: there is no point in replicating methods like + // filepath.Join here they are just path helpers that do not read/write + // to the filesystem. + // Currently, there is no requirement for using any filepath methods + // with the golang generator, hence nothing is defined here. We may + // want to replicate this filesystem model in other contexts, so this + // will serve as a reminder in the intended use of this interface. +} + +// ExistsInFS contains methods that check the existence of file system items. +type ExistsInFS interface { + // FileExists does file exist at the path specified + FileExists(path string) bool + + // DirectoryExists does directory exist at the path specified + DirectoryExists(path string) bool +} + +type ReadFromFS interface { + // Lstat, see https://pkg.go.dev/os#Lstat + Lstat(path string) (fs.FileInfo, error) + + // Lstat, see https://pkg.go.dev/os#Stat + Stat(path string) (fs.FileInfo, error) + + // ReadFile, see https://pkg.go.dev/os#ReadFile + ReadFile(name string) ([]byte, error) + + // ReadDir, see https://pkg.go.dev/os#ReadDir + ReadDir(name string) ([]os.DirEntry, error) +} + +// WriteToFS contains methods that perform mutative operations on the file system. +type WriteToFS interface { + + // Chmod, see https://pkg.go.dev/os#Chmod + Chmod(name string, mode os.FileMode) error + + // Chown, https://pkg.go.dev/os#Chown + Chown(name string, uid, gid int) error + + // Create, see https://pkg.go.dev/os#Create + Create(name string) (*os.File, error) + + // Link, see https://pkg.go.dev/os#Link + Link(oldname, newname string) error + + // Mkdir, see https://pkg.go.dev/os#Mkdir + Mkdir(name string, perm fs.FileMode) error + + // MkdirAll, see https://pkg.go.dev/os#MkdirAll + MkdirAll(path string, perm os.FileMode) error + + // Remove, see https://pkg.go.dev/os#Remove + Remove(name string) error + + // RemoveAll, see https://pkg.go.dev/os#RemoveAll + RemoveAll(path string) error + + // Rename, see https://pkg.go.dev/os#Rename + Rename(oldpath, newpath string) error + + // WriteFile, see https://pkg.go.dev/os#WriteFile + WriteFile(name string, data []byte, perm os.FileMode) error +} + +// ReadOnlyVirtualFS provides read-only access to the file system. +type ReadOnlyVirtualFS interface { + filepathAPI + ExistsInFS + ReadFromFS +} + +// VirtualFS is a facade over the native file system, which include read +// and write access. +type VirtualFS interface { + filepathAPI + ExistsInFS + ReadFromFS + WriteToFS + + Backend() VirtualBackend +} + +type VirtualBackend string diff --git a/storage/storage-suite_test.go b/storage/storage-suite_test.go new file mode 100644 index 0000000..9449e54 --- /dev/null +++ b/storage/storage-suite_test.go @@ -0,0 +1,13 @@ +package storage_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" //nolint:revive // ok + . "github.com/onsi/gomega" //nolint:revive // ok +) + +func TestStorage(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Storage Suite") +} diff --git a/storage/virtual-fs_test.go b/storage/virtual-fs_test.go new file mode 100644 index 0000000..c49b3ef --- /dev/null +++ b/storage/virtual-fs_test.go @@ -0,0 +1,439 @@ +package storage_test + +import ( + "fmt" + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" //nolint:revive // ok + . "github.com/onsi/gomega" //nolint:revive // ok + + "github.com/snivilised/li18ngo/internal/helpers" + "github.com/snivilised/li18ngo/storage" + "github.com/snivilised/li18ngo/translate" +) + +// it is not important for these tests to pass, because storage is +// eventually going to be removed entirely. We already know that the +// functionality works on past performance + +type virtualTE struct { + message string + should string + fn func(vfs storage.VirtualFS, isNative bool) +} + +func (v *virtualTE) action(vfs storage.VirtualFS, isNative bool) { + v.fn(vfs, isNative) +} + +var ( + faydeaudeau = os.FileMode(0o777) + beezledub = os.FileMode(0o666) +) + +func reason(backend storage.VirtualBackend, message string, actual, expected any) string { + return fmt.Sprintf("๐Ÿ”ฅ [%v:%v] expected '%v' to be '%v'", + backend, message, actual, expected, + ) +} + +type setupFile struct { + filePath string + data []byte +} + +func setupDirectory(fs storage.VirtualFS, directoryPath string) { + if e := fs.MkdirAll(directoryPath, faydeaudeau); e != nil { + Fail(e.Error()) + } +} + +func setupFiles(fs storage.VirtualFS, parentDir string, files ...*setupFile) { + setupDirectory(fs, parentDir) + + for _, f := range files { + if e := fs.WriteFile(f.filePath, f.data, beezledub); e != nil { + Fail(e.Error()) + } + } +} + +var _ = Describe("virtual-fs", Ordered, func() { + var ( + mfs storage.VirtualFS + nfs storage.VirtualFS + root, requiem string + ) + + BeforeAll(func() { + root = helpers.Repo("") + requiem = helpers.Path(root, "Test/data/storage/Nephilim/Mourning Sun/info.requiem.txt") + }) + + BeforeEach(func() { + mfs = storage.UseMemFS() + nfs = storage.UseNativeFS() + + if err := translate.Use(func(o *translate.UseOptions) { + o.Tag = translate.DefaultLanguage.Get() + }); err != nil { + Fail(err.Error()) + } + }) + + XDescribeTable("vfs", + func(entry *virtualTE) { + entry.action(mfs, false) + entry.action(nfs, true) + }, + + func(entry *virtualTE) string { + return fmt.Sprintf("๐Ÿงช ===> given: '%v', should: '%v'", + entry.message, entry.should, + ) + }, + + // --- ExistsInFS + + Entry(nil, &virtualTE{ + message: "FileExists", + should: "return correct existence status", + fn: func(vfs storage.VirtualFS, isNative bool) { + if !isNative { + setupFiles(vfs, root, &setupFile{ + filePath: requiem, + data: []byte("foo-bar"), + }) + } + actual := vfs.FileExists(requiem) + + Expect(actual).To(BeTrue(), + reason(vfs.Backend(), "file exists return error", actual, true), + ) + }, + }), + + Entry(nil, &virtualTE{ + message: "DirectoryExists", + should: "return correct existence status", + fn: func(vfs storage.VirtualFS, isNative bool) { + if !isNative { + setupDirectory(vfs, root) + } + + actual := vfs.DirectoryExists(root) + + Expect(actual).To(BeTrue(), + reason(vfs.Backend(), "directory exists return error", actual, true), + ) + }, + }), + + // --- end: ExistsInFS + + // --- ReadOnlyVirtualFS + + Entry(nil, &virtualTE{ + message: "Lstat", + should: "return correct file info", + fn: func(vfs storage.VirtualFS, isNative bool) { + if !isNative { + setupFiles(vfs, root, &setupFile{ + filePath: requiem, + data: []byte("requiem-content"), + }) + } + info, err := vfs.Lstat(requiem) + Expect(err).Error().To(BeNil()) + + expected := "info.requiem.txt" + actual := info.Name() + Expect(actual).To(Equal(expected), + reason(vfs.Backend(), "lstat return correct name", actual, expected), + ) + }, + }), + + Entry(nil, &virtualTE{ + message: "Stat", + should: "return correct file info", + fn: func(vfs storage.VirtualFS, isNative bool) { + if !isNative { + setupFiles(vfs, root, &setupFile{ + filePath: requiem, + data: []byte("requiem-content"), + }) + } + info, err := vfs.Stat(requiem) + Expect(err).Error().To(BeNil()) + + expected := "info.requiem.txt" + actual := info.Name() + Expect(actual).To(Equal(expected), + reason(vfs.Backend(), "lstat return correct name", actual, expected), + ) + }, + }), + + Entry(nil, &virtualTE{ + message: "ReadFile", + should: "return correct file content", + fn: func(vfs storage.VirtualFS, isNative bool) { + expected := "requiem-content" + + if !isNative { + setupFiles(vfs, root, &setupFile{ + filePath: requiem, + data: []byte(expected), + }) + } + content, err := vfs.ReadFile(requiem) + actual := string(content) + + Expect(actual).To(Equal(expected), + reason(vfs.Backend(), "read file return content", actual, expected), + ) + Expect(err).Error().To(BeNil()) + }, + }), + + Entry(nil, &virtualTE{ + message: "ReadDir", + should: "return correct read status", + fn: func(vfs storage.VirtualFS, isNative bool) { + expected := "requiem-content" + + if !isNative { + setupFiles(vfs, root, &setupFile{ + filePath: requiem, + data: []byte(expected), + }) + } + actual, err := vfs.ReadDir(root) + + Expect(actual).To(HaveLen(1), + reason(vfs.Backend(), "read directory return content", actual, expected), + ) + Expect(err).Error().To(BeNil()) + }, + }), + + // --- end: ReadOnlyVirtualFS + + // --- WriteToFS + + Entry(nil, &virtualTE{ + message: "Create", + should: "create file", + fn: func(vfs storage.VirtualFS, isNative bool) { + path := filepath.Join(root, "shroud.txt") + + if !isNative { + setupDirectory(vfs, root) + } + + file, err := vfs.Create(path) + if err == nil { + defer file.Close() + } + + defer func() { + _ = vfs.Remove(path) + }() + + Expect(err).Error().To(BeNil(), + reason(vfs.Backend(), "create file return error", err, nil), + ) + }, + }), + + // Chmod + // Chown + // Link + + Entry(nil, &virtualTE{ + message: "Mkdir", + should: "create all directory segments in path", + fn: func(vfs storage.VirtualFS, isNative bool) { + if isNative { + return // bypass due to potential of access denied in native-fs + } + + setupDirectory(vfs, root) + + path := filepath.Join(root, "__A") + actual := vfs.Mkdir(path, beezledub) + + Expect(actual).Error().To(BeNil(), + reason(vfs.Backend(), "Mkdir return error", actual, nil), + ) + Expect(vfs.DirectoryExists(path)).To(BeTrue()) + }, + }), + + Entry(nil, &virtualTE{ + message: "MkdirAll", + should: "create all directory segments in path", + fn: func(vfs storage.VirtualFS, isNative bool) { + if isNative { + return // bypass due to potential of access denied in native-fs + } + + setupDirectory(vfs, root) + + path := filepath.Join(root, "__A", "__B", "__C") + actual := vfs.MkdirAll(path, beezledub) + + Expect(actual).Error().To(BeNil(), + reason(vfs.Backend(), "MkdirAll return error", actual, nil), + ) + Expect(vfs.DirectoryExists(path)).To(BeTrue()) + }, + }), + + Entry(nil, &virtualTE{ + message: "Remove", + should: "remove file at path", + fn: func(vfs storage.VirtualFS, _ bool) { + path := filepath.Join(root, "shroud.txt") + setupFiles(vfs, root, &setupFile{ + filePath: path, + data: []byte("foo-bar"), + }) + + actual := vfs.Remove(path) + + Expect(actual).Error().To(BeNil(), + reason(vfs.Backend(), "remove file return error", actual, nil), + ) + }, + }), + + Entry(nil, &virtualTE{ + message: "RemoveAll", + should: "remove all at path", + fn: func(vfs storage.VirtualFS, _ bool) { + path := filepath.Join(root, "__A") + + setupFiles(vfs, path, + &setupFile{ + filePath: filepath.Join(path, "x.txt"), + data: []byte("x-content"), + }, + &setupFile{ + filePath: filepath.Join(path, "y.txt"), + data: []byte("y-content"), + }, + ) + + actual := vfs.RemoveAll(path) + + Expect(actual).Error().To(BeNil(), + reason(vfs.Backend(), "remove all at path return error", actual, nil), + ) + }, + }), + + Entry(nil, &virtualTE{ + message: "Rename", + should: "rename file at path", + fn: func(vfs storage.VirtualFS, isNative bool) { + path := filepath.Join(root, "shroud.txt") + destination := filepath.Join(root, "renamed-shroud.txt") + setupFiles(vfs, root, &setupFile{ + filePath: path, + data: []byte("foo-bar"), + }) + + actual := vfs.Rename(path, destination) + + if isNative { + defer func() { + _ = vfs.Remove(destination) + }() + } + + Expect(actual).Error().To(BeNil(), + reason(vfs.Backend(), "rename return error", actual, nil), + ) + Expect(vfs.FileExists(destination)).To(BeTrue()) + }, + }), + + Entry(nil, &virtualTE{ + message: "Move(Rename) file to different directory", + should: "move file at path to new directory", + fn: func(vfs storage.VirtualFS, isNative bool) { + if isNative { + return + } + filename := "shroud.txt" + sourceDir := filepath.Join(root, "source-d") + sourceFile := filepath.Join(sourceDir, filename) + setupFiles(vfs, sourceDir, &setupFile{ + filePath: sourceFile, + data: []byte("foo-bar"), + }) + destinationDir := filepath.Join(root, "destination-d") + destinationFile := filepath.Join(destinationDir, filename) + setupDirectory(vfs, destinationDir) + + actual := vfs.Rename(sourceFile, destinationFile) + + Expect(actual).Error().To(BeNil(), + reason(vfs.Backend(), "rename(move) return error", actual, nil), + ) + Expect(vfs.FileExists(destinationFile)).To(BeTrue()) + }, + }), + + Entry(nil, &virtualTE{ + message: "Move(Rename) directory to different directory", + should: "move directory at path to new directory", + fn: func(vfs storage.VirtualFS, isNative bool) { + if isNative { + return + } + item := "item" + sourceDir := filepath.Join(root, item) + setupDirectory(vfs, sourceDir) + + parentDir := filepath.Join(root, "parent") + setupDirectory(vfs, parentDir) + + destinationDir := filepath.Join(parentDir, item) + actual := vfs.Rename(sourceDir, destinationDir) + + Expect(actual).Error().To(BeNil(), + reason(vfs.Backend(), "rename(move) return error", actual, nil), + ) + Expect(vfs.DirectoryExists(destinationDir)).To(BeTrue()) + }, + }), + + Entry(nil, &virtualTE{ + message: "WriteFile", + should: "write file to path", + fn: func(vfs storage.VirtualFS, isNative bool) { + setupDirectory(vfs, root) + path := filepath.Join(root, "shroud.txt") + + content := []byte("Mourning Sun") + actual := vfs.WriteFile(path, content, beezledub) + + if isNative { + defer func() { + _ = vfs.Remove(path) + }() + } + + Expect(actual).Error().To(BeNil(), + reason(vfs.Backend(), "write file return error", actual, nil), + ) + }, + }), + + // --- end: WriteToFS + ) +}) diff --git a/translate/i18n-messages_test.go b/translate/i18n-messages_test.go new file mode 100644 index 0000000..94ae685 --- /dev/null +++ b/translate/i18n-messages_test.go @@ -0,0 +1,50 @@ +package translate_test + +import ( + "github.com/nicksnyder/go-i18n/v2/i18n" +) + +const ( + GrafficoSourceID = "github.com/snivilised/graffico" +) + +type GrafficoData struct{} + +func (td GrafficoData) SourceID() string { + return GrafficoSourceID +} + +// ๐ŸงŠ Pavement Graffiti Report + +// PavementGraffitiReportTemplData +type PavementGraffitiReportTemplData struct { + GrafficoData + Primary string +} + +func (td PavementGraffitiReportTemplData) Message() *i18n.Message { + return &i18n.Message{ + ID: "pavement-graffiti-report.graffico.unit-test", + Description: "Report of graffiti found on a pavement", + Other: "Found graffiti on pavement; primary colour: '{{.Primary}}'", + } +} + +// โ˜ข๏ธ Wrong Source Id + +// WrongSourceIDTemplData +type WrongSourceIDTemplData struct { + GrafficoData +} + +func (td WrongSourceIDTemplData) SourceID() string { + return "FOO-BAR" +} + +func (td WrongSourceIDTemplData) Message() *i18n.Message { + return &i18n.Message{ + ID: "wrong-source-id.graffico.unit-test", + Description: "Incorrect Source Id for which doesn't match the one n the localizer", + Other: "Message with wrong id", + } +} diff --git a/translate/localisable-error.go b/translate/localisable-error.go new file mode 100644 index 0000000..27f86a2 --- /dev/null +++ b/translate/localisable-error.go @@ -0,0 +1,30 @@ +package translate + +import ( + "reflect" +) + +// LocalisableError is an error that is translate-able (Localisable) +type LocalisableError struct { + Data Localisable +} + +func (le LocalisableError) Error() string { + return Text(le.Data) +} + +func QueryGeneric[T any](method string, target error) bool { + if target == nil { + return false + } + + nativeIf, ok := target.(T) + + if !ok { + return false + } + + none := []reflect.Value{} + + return reflect.ValueOf(&nativeIf).Elem().MethodByName(method).Call(none)[0].Bool() +} diff --git a/translate/localisation-errors.go b/translate/localisation-errors.go new file mode 100644 index 0000000..0e2c8ee --- /dev/null +++ b/translate/localisation-errors.go @@ -0,0 +1,43 @@ +package translate + +import ( + "fmt" + + "github.com/pkg/errors" + "golang.org/x/text/language" +) + +// NB: These errors occur prior to or during the process of creating a localizer +// which by definition means translated content can't be served to the client using +// the requested locale and therefore have to be displayed untranslated. + +// โŒ Could Not Find Localizer + +// NewFailedToCreateLocalizerNativeError creates an untranslated error to +// indicate the Translator already contains a localizer for the source +// specified. (internal error) +func NewCouldNotFindLocalizerNativeError(sourceID string) error { + return fmt.Errorf( + "i18n: could not find localizer for source: '%v'", sourceID, + ) +} + +// โŒ Could Not Load Translations + +// NewCouldNotLoadTranslationsNativeError creates an untranslated error to +// indicate translations file could not be loaded +func NewCouldNotLoadTranslationsNativeError(tag language.Tag, path string, reason error) error { + return errors.Wrapf( + reason, "i18n: could not load translations for '%v', from: '%v'", tag, path, + ) +} + +// โŒ Failed To Create Translator + +// NewFailedToCreateTranslatorNativeError creates an untranslated error to +// indicate failure to create a Translator instance +func NewFailedToCreateTranslatorNativeError(tag language.Tag) error { + return fmt.Errorf( + "i18n: failed to create translator for language '%v'", tag, + ) +} diff --git a/translate/localizer-factory.go b/translate/localizer-factory.go new file mode 100644 index 0000000..0f06f08 --- /dev/null +++ b/translate/localizer-factory.go @@ -0,0 +1,63 @@ +package translate + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/nicksnyder/go-i18n/v2/i18n" + "github.com/snivilised/li18ngo/internal/lo" + "github.com/snivilised/li18ngo/utils" + "golang.org/x/text/language" +) + +func createLocalizer(lang *LanguageInfo, sourceID string) (*Localizer, error) { + bundle := i18n.NewBundle(lang.Tag) + bundle.RegisterUnmarshalFunc("json", json.Unmarshal) + + if lang.Tag != lang.Default { + txSource := lang.From.Sources[sourceID] + path := resolveBundlePath(lang, txSource) + _, err := bundle.LoadMessageFile(path) + + if (err != nil) && (!lang.DefaultIsAcceptable) { + return nil, NewCouldNotLoadTranslationsNativeError(lang.Tag, path, err) + } + } + + supported := lo.Map(lang.Supported, func(t language.Tag, _ int) string { + return t.String() + }) + + return i18n.NewLocalizer(bundle, supported...), nil +} + +func resolveBundlePath(lang *LanguageInfo, txSource TranslationSource) string { + filename := lo.TernaryF(txSource.Name == "", + func() string { + return fmt.Sprintf("active.%v.json", lang.Tag) + }, + func() string { + return fmt.Sprintf("%v.active.%v.json", txSource.Name, lang.Tag) + }, + ) + + path := lo.Ternary(txSource.Path != "" && utils.FolderExists(txSource.Path), + txSource.Path, + lang.From.Path, + ) + + directory := lo.TernaryF(path != "" && utils.FolderExists(path), + func() string { + resolved, _ := filepath.Abs(path) + return resolved + }, + func() string { + exe, _ := os.Executable() + return filepath.Dir(exe) + }, + ) + + return filepath.Join(directory, filename) +} diff --git a/translate/multiplexor-container.go b/translate/multiplexor-container.go new file mode 100644 index 0000000..c734753 --- /dev/null +++ b/translate/multiplexor-container.go @@ -0,0 +1,36 @@ +package translate + +type multiplexor struct { +} + +func (mx *multiplexor) invoke(localizer *Localizer, data Localisable) string { + return localizer.MustLocalize(&LocalizeConfig{ + DefaultMessage: data.Message(), + TemplateData: data, + }) +} + +type multiContainer struct { + multiplexor + localizers localizerContainer +} + +func (mc *multiContainer) localise(data Localisable) string { + return mc.invoke(mc.find(data.SourceID()), data) +} + +func (mc *multiContainer) add(info *LocalizerInfo) { + if _, found := mc.localizers[info.sourceID]; found { + return + } + + mc.localizers[info.sourceID] = info.Localizer +} + +func (mc *multiContainer) find(id string) *Localizer { + if loc, found := mc.localizers[id]; found { + return loc + } + + panic(NewCouldNotFindLocalizerNativeError(id)) +} diff --git a/translate/translate-defs.go b/translate/translate-defs.go new file mode 100644 index 0000000..cb3426b --- /dev/null +++ b/translate/translate-defs.go @@ -0,0 +1,128 @@ +package translate + +import ( + "github.com/nicksnyder/go-i18n/v2/i18n" + "golang.org/x/text/language" +) + +type Message = i18n.Message +type Localizer = i18n.Localizer +type LocalizeConfig = i18n.LocalizeConfig + +// Li18ngoSourceID the id that represents this module. If client want +// to provides translations for languages that li18ngo does not, then +// the localizer the create created for this purpose should use this +// SourceID. So whenever the Text function is used on templates defined +// inside this module, the translation process is directed to use the +// correct i18n.Localizer (identified by the SourceID). The Source is +// statically defined for all templates defined in li18ngo. +const Li18ngoSourceID = "github.com/snivilised/li18ngo" + +type Localisable interface { + Message() *Message + SourceID() string +} + +type SupportedLanguages []language.Tag + +// LoadFrom denotes where to load the translation file from +type LoadFrom struct { + // Path denoting where to load language file from, defaults to exe location + // + Path string + + // Sources are the translation files that need to be loaded. They represent + // the client app/library its dependencies. + // + // The source id would typically be the name of a package that is the source + // of string messages that are to be translated. Actually, we could use + // the top level url of the package by convention, as that is unique. + // So li18ngo would use "github.com/snivilised/li18ngo" but clients + // are free to use whatever naming scheme they want to use for their own + // dependencies. + // + Sources TranslationFiles +} + +// AddSource adds a translation source +func (lf *LoadFrom) AddSource(sourceID string, source *TranslationSource) { + if _, found := lf.Sources[sourceID]; !found { + lf.Sources[sourceID] = *source + } +} + +type TranslationSource struct { + // Name of dependency's translation file + Name string + Path string +} + +// TranslationFiles maps a source id to a TranslationSource +type TranslationFiles map[string]TranslationSource + +// UseOptions the options provided to the Use function +type UseOptions struct { + // Tag sets the language to use + // + Tag language.Tag + + // From denotes where to load the translation file from + // + From LoadFrom + + // DefaultIsAcceptable controls whether an error is returned if the + // request language is not available. By default DefaultIsAcceptable + // is true so that the application continues in the default language + // even if the requested language is not available. + // + DefaultIsAcceptable bool + + // Create allows the client to override the default function to create + // the i18n Localizer(s) (1 per language). + // + Create LocalizerCreatorFn + + // Custom set-able by the client for what ever purpose is required. + // + Custom any +} + +// LanguageInfo information pertaining to setting language. Auto detection +// is not supported. Any executable that supports i18n, should perform +// auto detection and then invoke Use, with the detected language tag + +type LanguageInfo struct { + UseOptions + + // Default language reflects the base language. If all else fails, messages will + // be in this language. It is fixed at BritishEnglish reflecting the language this + // package is written in. + // + Default language.Tag + + // Supported indicates the list of languages for which translations are available. + // + Supported SupportedLanguages +} + +// UseOptionFn functional options function required by Use. +type UseOptionFn func(*UseOptions) + +// type localizerMultiplexor interface { +// localise(data Localisable) string +// } + +// LocalizerInfo +type LocalizerInfo struct { + // Localizer by default created internally, but can be overridden by + // the client if they provide a create function to the Translator Factory + // + Localizer *Localizer + + sourceID string +} + +// TranslatorFactory +type TranslatorFactory interface { + New(lang *LanguageInfo) Translator +} diff --git a/translate/translate-suite_test.go b/translate/translate-suite_test.go new file mode 100644 index 0000000..7665b3a --- /dev/null +++ b/translate/translate-suite_test.go @@ -0,0 +1,13 @@ +package translate_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" //nolint:revive // ok + . "github.com/onsi/gomega" //nolint:revive // ok +) + +func TestTranslate(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Translate Suite") +} diff --git a/translate/translator-factories.go b/translate/translator-factories.go new file mode 100644 index 0000000..42466da --- /dev/null +++ b/translate/translator-factories.go @@ -0,0 +1,63 @@ +package translate + +import ( + "github.com/snivilised/li18ngo/utils" +) + +// LocalizerCreatorFn represents the signature of the function can optionally +// provide to override how an i18n Localizer is created. +type LocalizerCreatorFn func(li *LanguageInfo, sourceID string) (*Localizer, error) + +type AbstractTranslatorFactory struct { + Create LocalizerCreatorFn + legacy Translator +} + +func (f *AbstractTranslatorFactory) setup(lang *LanguageInfo) { + verifyLanguage(lang) + + if f.Create == nil { + f.Create = createLocalizer + } +} + +// multiTranslatorFactory creates a translator instance from the provided +// Localizers. +// +// Note, in the case where a source client wants to provide a localizer +// for a language that one of ite dependencies does not support, then +// the translator should create the localizer based on its own default +// language, but we load the client provided translation file at the same +// name as the dependency would have created it for, then this file will +// be loaded as per usual. +type multiTranslatorFactory struct { + AbstractTranslatorFactory +} + +func (f *multiTranslatorFactory) New(lang *LanguageInfo) Translator { + f.setup(lang) + + liRef := utils.NewRoProp(lang) + multi := &multiContainer{ + localizers: make(localizerContainer), + } + + for id := range lang.From.Sources { + localizer, err := f.Create(lang, id) + + if err != nil { + panic(err) + } + + multi.add(&LocalizerInfo{ + sourceID: id, + Localizer: localizer, + }) + } + + return &i18nTranslator{ + mx: multi, + languageInfo: lang, + languageInfoRef: liRef, + } +} diff --git a/translate/translator.go b/translate/translator.go new file mode 100644 index 0000000..78fe271 --- /dev/null +++ b/translate/translator.go @@ -0,0 +1,196 @@ +package translate + +import ( + "github.com/snivilised/li18ngo/internal/lo" + "github.com/snivilised/li18ngo/utils" + "golang.org/x/text/language" +) + +type Translator interface { + Localise(data Localisable) string + LanguageInfoRef() utils.RoProp[*LanguageInfo] + negotiate(other Translator) Translator + add(info *LocalizerInfo, source *TranslationSource) +} + +var DefaultLanguage = utils.NewRoProp(language.BritishEnglish) +var tx Translator +var TxRef utils.RoProp[Translator] = utils.NewRoProp(tx) + +type localizerContainer map[string]*Localizer + +// Use, must be called by the client before any string data +// can be translated. If the client requests the default +// language, then only the language Tag needs to be provided. +// If the requested language is not the default and therefore +// requires translation from the translation file(s), then +// the client must provide the App and Path properties indicating +// how the l18n bundle is created. +// If the client just wishes to use the Default language, then Use +// can even be called without specifying the Tag and in this case +// the default language will be used. The client MUST call Use +// before using any functionality in this package. +func Use(options ...UseOptionFn) error { + var err error + + o := &UseOptions{} + + o.DefaultIsAcceptable = true + o.Tag = DefaultLanguage.Get() + + for _, fo := range options { + fo(o) + } + + lang := NewLanguageInfo(o) + + if !containsLanguage(lang.Supported, o.Tag) { + if o.DefaultIsAcceptable { + o.Tag = DefaultLanguage.Get() + lang.Tag = o.Tag + } else { + err = NewFailedToCreateTranslatorNativeError(o.Tag) + } + } + + if err == nil { + applyLanguage(lang) + } + + return err +} + +func verifyLanguage(lang *LanguageInfo) { + if lang.From.Sources == nil { + lang.From.Sources = make(TranslationFiles) + } + + // By adding in the source for li18ngo, we relieve the client from having + // to do this. After-all, it should be taken as read that if the client is + // using li18ngo then the translations for li18ngo should be loaded, + // otherwise li18ngo will not be able to convey these translations to the + // client. The client app has to make sure that when their app is deployed, + // the translations file(s) for li18ngo are named as 'li18ngo', as you + // can see below, that that is the name assigned to the app name of the + // source. There is little value in making this customisable as this would + // just lead to confusion. If the client really wants to control the name + // of the translation file for li18ngo, they can provide an override + // 'Create' function on UseOptions. + // + if _, found := lang.From.Sources[Li18ngoSourceID]; !found { + lang.From.Sources[Li18ngoSourceID] = TranslationSource{Name: "li18ngo"} + } +} + +func applyLanguage(lang *LanguageInfo) { + verifyLanguage(lang) + factory := &multiTranslatorFactory{ + AbstractTranslatorFactory: AbstractTranslatorFactory{ + Create: lang.Create, + legacy: tx, + }, + } + + newTranslator := factory.New(lang) + tx = negotiateTranslators(tx, newTranslator) + + TxRef = utils.NewRoProp(tx) +} + +func negotiateTranslators(legacyTX, incomingTX Translator) Translator { + result := incomingTX + + if legacyTX != nil { + result = legacyTX.negotiate(incomingTX) + } + + return result +} + +// ResetTx, do not use, required for unit testing only and is +// not considered part of the public api and may be removed without +// corresponding version number change. +func ResetTx() { + // having to do this smells a bit, but required so unit tests can + // remain isolated (this is why package globals are bad, but sometimes + // unavoidable). This is all because we want to be able to call the Text + // function easily. If we defined the Text function on an object, then that + // would require passing that state around in many places, making the code + // much more brittle and cumbersome to maintain. + // + tx = nil + TxRef = utils.NewRoProp(tx) +} + +// NewLanguageInfo gets a new instance of Language info from the use options +// specified. This is specific to li18ngo. Client applications should +// provide their own version that reflects their own defaults. +func NewLanguageInfo(o *UseOptions) *LanguageInfo { + return &LanguageInfo{ + UseOptions: *o, + Default: DefaultLanguage.Get(), + Supported: SupportedLanguages{ + DefaultLanguage.Get(), + language.AmericanEnglish, + }, + } +} + +// Text is the function to use to obtain a string created from +// registered Localizers. The data parameter must be a go template +// defining the input parameters and the translatable message content. +func Text(data Localisable) string { + return tx.Localise(data) +} + +// i18nTranslator provides the translation implementation used by the +// Text function +type i18nTranslator struct { + mx *multiContainer + languageInfo *LanguageInfo + languageInfoRef utils.RoProp[*LanguageInfo] +} + +func (t *i18nTranslator) LanguageInfoRef() utils.RoProp[*LanguageInfo] { + return t.languageInfoRef +} + +func (t *i18nTranslator) Localise(data Localisable) string { + return t.mx.localise(data) +} + +func containsLanguage(languages SupportedLanguages, tag language.Tag) bool { + return lo.ContainsBy(languages, func(t language.Tag) bool { + return t == tag + }) +} + +func (t *i18nTranslator) negotiate(incomingTX Translator) Translator { + incomingLang := incomingTX.LanguageInfoRef().Get() + legacyLang := t.LanguageInfoRef().Get() + incTX, ok := incomingTX.(*i18nTranslator) + + if !ok { + panic("unexpected incoming translator instance (not i18nTranslator)") + } + + legacySources := legacyLang.From.Sources + incomingSources := incomingLang.From.Sources + + for sourceID, source := range incomingSources { + if _, found := legacySources[sourceID]; !found { + s := source // copy required to avoid "implicit memory aliasing in for loop" + t.add(&LocalizerInfo{ + Localizer: incTX.mx.localizers[sourceID], + sourceID: sourceID, + }, &s) + } + } + + return t +} + +func (t *i18nTranslator) add(info *LocalizerInfo, source *TranslationSource) { + t.mx.add(info) + t.languageInfo.From.AddSource(info.sourceID, source) +} diff --git a/utils/ensure-path-at.go b/utils/ensure-path-at.go new file mode 100644 index 0000000..f9cdba5 --- /dev/null +++ b/utils/ensure-path-at.go @@ -0,0 +1,47 @@ +package utils + +import ( + "os" + "path/filepath" + "strings" + + "github.com/snivilised/li18ngo/storage" +) + +// EnsurePathAt ensures that the specified path exists (including any non +// existing intermediate directories). Given a path and a default filename, +// the specified path is created in the following manner: +// - If the path denotes a file (path does not end is a directory separator), then +// the parent folder is created if it doesn't exist on the file-system provided. +// - If the path denotes a directory, then that directory is created. +// +// The returned string represents the file, so if the path specified was a +// directory path, then the defaultFilename provided is joined to the path +// and returned, otherwise the original path is returned un-modified. +// Note: filepath.Join does not preserve a trailing separator, therefore to make sure +// a path is interpreted as a directory and not a file, then the separator has +// to be appended manually onto the end of the path. +// If vfs is not provided, then the path is ensured directly on the native file +// system. +func EnsurePathAt(path, defaultFilename string, perm int, vfs ...storage.VirtualFS) (at string, err error) { + var ( + directory, file string + ) + + if strings.HasSuffix(path, string(os.PathSeparator)) { + directory = path + file = defaultFilename + } else { + directory, file = filepath.Split(path) + } + + if len(vfs) > 0 { + if !vfs[0].DirectoryExists(directory) { + err = vfs[0].MkdirAll(directory, os.FileMode(perm)) + } + } else { + err = os.MkdirAll(directory, os.FileMode(perm)) + } + + return filepath.Clean(filepath.Join(directory, file)), err +} diff --git a/utils/ensure-path-at_test.go b/utils/ensure-path-at_test.go new file mode 100644 index 0000000..df73066 --- /dev/null +++ b/utils/ensure-path-at_test.go @@ -0,0 +1,80 @@ +package utils_test + +import ( + "fmt" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" //nolint:revive // ok + . "github.com/onsi/gomega" //nolint:revive // ok + + "github.com/snivilised/li18ngo/internal/helpers" + "github.com/snivilised/li18ngo/matchers" + "github.com/snivilised/li18ngo/storage" + "github.com/snivilised/li18ngo/utils" +) + +type ensureTE struct { + given string + should string + relative string + expected string + directory bool +} + +const perm = 0o766 + +var _ = Describe("EnsurePathAt", Ordered, func() { + var ( + vfs storage.VirtualFS + mocks *utils.ResolveMocks + ) + + BeforeEach(func() { + mocks = &utils.ResolveMocks{ + HomeFunc: func() (string, error) { + return filepath.Join(string(filepath.Separator), "home", "prodigy"), nil + }, + AbsFunc: func(_ string) (string, error) { + panic("not required for these tests") + }, + } + + vfs = storage.UseMemFS() + }) + + DescribeTable("with vfs", + func(entry *ensureTE) { + home, _ := mocks.HomeFunc() + location := filepath.Join(home, entry.relative) + if entry.directory { + location += string(filepath.Separator) + } + + actual, err := utils.EnsurePathAt(location, "default-test.log", perm, vfs) + directory, _ := filepath.Split(actual) + expected := helpers.Path(home, entry.expected) + + Expect(err).Error().To(BeNil()) + Expect(actual).To(Equal(expected)) + Expect(matchers.AsDirectory(directory)).To(matchers.ExistInFS(vfs)) + }, + func(entry *ensureTE) string { + return fmt.Sprintf("๐Ÿงช ===> given: '%v', should: '%v'", entry.given, entry.should) + }, + + Entry(nil, &ensureTE{ + given: "path with file", + should: "create parent directory and return specified file path", + relative: "logs/test.log", + expected: "logs/test.log", + }), + + Entry(nil, &ensureTE{ + given: "path with file", + should: "create parent directory and return default file path", + relative: "logs/", + directory: true, + expected: "logs/default-test.log", + }), + ) +}) diff --git a/utils/exists.go b/utils/exists.go new file mode 100644 index 0000000..6e6cac4 --- /dev/null +++ b/utils/exists.go @@ -0,0 +1,38 @@ +package utils + +import ( + "os" +) + +// Exists provides a simple way to determine whether the item identified by a +// path actually exists either as a file or a folder +func Exists(path string) bool { + result := false + if _, err := os.Stat(path); err == nil { + result = true + } + + return result +} + +// FileExists provides a simple way to determine whether the item identified by a +// path actually exists as a file +func FileExists(path string) bool { + result := false + if info, err := os.Lstat(path); err == nil { + result = !info.IsDir() + } + + return result +} + +// FileExists provides a simple way to determine whether the item identified by a +// path actually exists as a folder +func FolderExists(path string) bool { + result := false + if info, err := os.Lstat(path); err == nil { + result = info.IsDir() + } + + return result +} diff --git a/utils/exists_test.go b/utils/exists_test.go new file mode 100644 index 0000000..a697c61 --- /dev/null +++ b/utils/exists_test.go @@ -0,0 +1,73 @@ +package utils_test + +import ( + "fmt" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" //nolint:revive // ok + . "github.com/onsi/gomega" //nolint:revive // ok + "github.com/snivilised/li18ngo/internal/helpers" + + "github.com/snivilised/li18ngo/utils" +) + +func path(parent, relative string) string { + segments := strings.Split(relative, "/") + return filepath.Join(append([]string{parent}, segments...)...) +} + +var _ = Describe("Exists Utils", Ordered, func() { + var repo string + + BeforeAll(func() { + repo = helpers.Repo("") + Expect(utils.FolderExists(repo)).To(BeTrue()) + }) + + DescribeTable("Exists", + func(_, relative string, expected bool, msg string) { + path := path(repo, relative) + + GinkgoWriter.Printf("---> ๐Ÿ”ฐ FULL-PATH: '%v'\n", path) + Expect(utils.Exists(path)).To(Equal(expected), msg) + }, + + func(message, _ string, _ bool, _ string) string { + return fmt.Sprintf("๐Ÿฅฃ message: '%v'", message) + }, + Entry(nil, "folder exists", "/", true, "failed: root path should exist"), + Entry(nil, "file exists", "README.md", true, "failed: README.md path should exist"), + Entry(nil, "does not exist", "foo-bar", false, "failed: foo-bar path should not exist"), + ) + + DescribeTable("FolderExists", + func(_, relative string, expected bool, msg string) { + path := path(repo, relative) + GinkgoWriter.Printf("---> ๐Ÿ”ฐ FULL-PATH: '%v'\n", path) + + Expect(utils.FolderExists(path)).To(Equal(expected), msg) + }, + func(message, _ string, _ bool, _ string) string { + return fmt.Sprintf("๐Ÿค message: '%v'", message) + }, + Entry(nil, "folder exists", "/", true, "failed: root folder should exist"), + Entry(nil, "exists as file", "README.md", false, "failed: README.md file should exist"), + Entry(nil, "folder does not exist", "foo-bar", false, "failed: foo-bar folder should not exist"), + ) + + DescribeTable("FileExists", + func(_, relative string, expected bool, msg string) { + path := path(repo, relative) + GinkgoWriter.Printf("---> ๐Ÿ”ฐ FULL-PATH: '%v'\n", path) + + Expect(utils.FileExists(path)).To(Equal(expected), msg) + }, + func(message, _ string, _ bool, _ string) string { + return fmt.Sprintf("๐Ÿค message: '%v'", message) + }, + Entry(nil, "file exists", "README.md", true, "failed: root file should exist"), + Entry(nil, "file does not exist", "foo-bar", false, "failed: foo-bar file should not exist"), + Entry(nil, "does not exist as file", "Test", false, "failed: Test file should not exist"), + ) +}) diff --git a/utils/is-nil.go b/utils/is-nil.go new file mode 100644 index 0000000..7279de1 --- /dev/null +++ b/utils/is-nil.go @@ -0,0 +1,22 @@ +package utils + +import ( + "reflect" +) + +func IsNil(i interface{}) bool { + value := reflect.ValueOf(i) + if !value.IsValid() { + return true + } + + kind := value.Kind() + + switch kind { //nolint:exhaustive // default case IS present to handle all other cases + case reflect.Chan, reflect.Func, reflect.Map, reflect.Pointer, reflect.Interface, reflect.Slice: + return reflect.ValueOf(i).IsNil() + + default: + return false + } +} diff --git a/utils/is-nil_test.go b/utils/is-nil_test.go new file mode 100644 index 0000000..f371771 --- /dev/null +++ b/utils/is-nil_test.go @@ -0,0 +1,57 @@ +package utils_test + +import ( + . "github.com/onsi/ginkgo/v2" //nolint:revive // ok + . "github.com/onsi/gomega" //nolint:revive // ok + + "github.com/snivilised/li18ngo/utils" +) + +type blob struct{} + +var _ = Describe("IsNil", func() { + + When("received item is not nil", func() { + Context("pointer to struct", func() { + It("๐Ÿงช should: return false", func() { + item := &blob{} + utils.IsNil(item) + Expect(utils.IsNil(item)).To(BeFalse()) + }) + }) + + Context("interface", func() { + It("๐Ÿงช should: return false", func() { + var item interface{} = &blob{} + utils.IsNil(item) + Expect(utils.IsNil(item)).To(BeFalse()) + }) + }) + + Context("struct", func() { + It("๐Ÿงช should: return false", func() { + item := blob{} + utils.IsNil(item) + Expect(utils.IsNil(item)).To(BeFalse()) + }) + }) + }) + + When("received item is a nil", func() { + Context("pointer to struct", func() { + It("๐Ÿงช should: return true", func() { + var item *blob + utils.IsNil(item) + Expect(utils.IsNil(item)).To(BeTrue()) + }) + }) + + Context("interface", func() { + It("๐Ÿงช should: return true", func() { + var item interface{} + utils.IsNil(item) + Expect(utils.IsNil(item)).To(BeTrue()) + }) + }) + }) +}) diff --git a/utils/member-property.go b/utils/member-property.go new file mode 100644 index 0000000..26bc86e --- /dev/null +++ b/utils/member-property.go @@ -0,0 +1,189 @@ +package utils + +import "reflect" + +// ============================================================== interfaces === + +// RoProp const property interface. +type RoProp[T any] interface { + Get() T + IsNone() bool +} + +// RwProp variable property interface +type RwProp[T any] interface { + RoProp[T] + Set(value T) + IsZeroable() bool + RoRef() RoProp[T] +} + +// PutProp putter variable property interface. The putter allows +// the client to define assignment using a client defined function. +// The putter will still set the property's Field value. +type PutProp[T any] interface { + RwProp[T] + Put(value T) +} + +// ===================================================================== New === + +// NewRoProp create const property +func NewRoProp[T any](value T) RoProp[T] { + return &constProp[T]{field: value} +} + +// NewRwProp create variable property +func NewRwProp[T any](value T) RwProp[T] { + return &VarProp[T]{Field: value} +} + +// NewRwProp create variable and zeroable property +func NewRwPropZ[T any](value T) RwProp[T] { + return &VarProp[T]{Field: value, zeroable: true} +} + +// NewPutProp create putter variable property +func NewPutProp[T any](value T, putter func(value T)) PutProp[T] { + return &putVarProp[T]{ + VarProp[T]{Field: value}, + putter, + } +} + +// NewPutProp create putter variable and zeroable property +func NewPutPropZ[T any](value T, putter func(value T)) PutProp[T] { + return &putVarProp[T]{ + VarProp[T]{Field: value, zeroable: true}, + putter, + } +} + +// =============================================================== factories === + +// RoPropFactory const property factory +type RoPropFactory[T any] struct { + Zeroable bool +} + +// New const property constructor +func (f RoPropFactory[T]) New(value T) RoProp[T] { + return &constProp[T]{field: value} +} + +// RwPropFactory variable property factory +type RwPropFactory[T any] struct { + Zeroable bool +} + +// New variable property constructor +func (f RwPropFactory[T]) New(value T) RwProp[T] { + return &VarProp[T]{Field: value, zeroable: f.Zeroable} +} + +// PutPropFactory putter variable property factory +type PutPropFactory[T any] struct { + Zeroable bool +} + +// New putter variable property constructor +func (f PutPropFactory[T]) New(value T, putter func(value T)) PutProp[T] { + return &putVarProp[T]{ + VarProp[T]{Field: value, zeroable: f.Zeroable}, + putter, + } +} + +// =============================================================== constProp === + +type constProp[T any] struct { + // constProp is not exported to prevent the client from + // creating a const property without a value, which is + // of no practical use as it can't be set later on. + // The client should use either RoPropFactory or NewRoProp. + // + field T +} + +// Get property value getter +func (p *constProp[T]) Get() T { + return p.field +} + +// IsNone determines whether the property has a value set +func (p *constProp[T]) IsNone() bool { + return isPropNil(p.field, false) +} + +// ================================================================= VarProp === + +// VarProp a read/write property +type VarProp[T any] struct { + zeroable bool + Field T +} + +// Get property value getter +func (p *VarProp[T]) Get() T { + return p.Field +} + +// Set property value setter +func (p *VarProp[T]) Set(value T) { + p.Field = value +} + +// IsNone determines whether the property has a value set +func (p *VarProp[T]) IsNone() bool { + return isPropNil(p.Field, p.zeroable) +} + +// IsZeroable indicates whether a zero value is a valid value for this property +func (p *VarProp[T]) IsZeroable() bool { + return p.zeroable +} + +// RoRef returns a read only reference to this property +func (p *VarProp[T]) RoRef() RoProp[T] { + return p +} + +// ============================================================== putVarProp === + +type putVarProp[T any] struct { + VarProp[T] + putter func(value T) +} + +// Put set the value of the property and invoke the putter function +func (p *putVarProp[T]) Put(value T) { + p.Set(value) + p.putter(value) +} + +// ==================================================================== misc === + +func isPropNil[T any](value T, zeroable bool) bool { + refV := reflect.ValueOf(value) + refK := refV.Kind() + + if refK == 0 { + // value has not been set yet + // + return true + } + + switch refK { //nolint:exhaustive // other cases handled below by design + case + reflect.Chan, + reflect.Func, + reflect.Interface, + reflect.Map, + reflect.Pointer, + reflect.Slice, + reflect.UnsafePointer: + return IsNil(value) + } + + return !zeroable && refV.IsZero() +} diff --git a/utils/member-property_test.go b/utils/member-property_test.go new file mode 100644 index 0000000..c7a9b34 --- /dev/null +++ b/utils/member-property_test.go @@ -0,0 +1,553 @@ +package utils_test + +import ( + . "github.com/onsi/ginkgo/v2" //nolint:revive // ok + . "github.com/onsi/gomega" //nolint:revive // ok + + "github.com/snivilised/li18ngo/utils" +) + +type slugInfo struct { + count utils.VarProp[int] +} + +type sizeable interface { + Measure() int +} + +type nugget struct { + size int +} + +func (n *nugget) Measure() int { + return n.size +} + +type fnProperty func() float64 +type slice []int +type dictionary map[int]string +type intChan chan int +type widget struct { + // variable properties + // + colour utils.VarProp[string] + slug utils.VarProp[*slugInfo] + quantity utils.VarProp[sizeable] + gold utils.VarProp[nugget] + fn utils.VarProp[fnProperty] + points utils.VarProp[slice] + numbers utils.VarProp[dictionary] + tunnel utils.VarProp[intChan] + fraction utils.RwProp[float64] + + // putter variable properties + // + rank utils.PutProp[int] + + // const properties + // + colourRo utils.RoProp[string] +} + +const pi5dp = 3.14159 + +func pi() float64 { + return pi5dp +} + +var evens = dictionary{ + 2: "Two", + 4: "Four", + 6: "Six", +} + +var _ = Describe("Property", func() { + + Context("given: string property", func() { + Context("VarProp", func() { + Context("Get", func() { + It("should: retrieve property value", func() { + w := &widget{ + colour: utils.VarProp[string]{Field: "red"}, + } + Expect(w.colour.Get()).To(Equal("red")) + }) + }) + + Context("Set", func() { + It("should: set property value", func() { + w := &widget{ + colour: utils.VarProp[string]{Field: "red"}, + } + w.colour.Set("blue") + Expect(w.colour.Get()).To(Equal("blue")) + }) + }) + + Context("IsNone", func() { + When("string value is previously defined", func() { + It("should: return false without panic", func() { + w := &widget{ + colour: utils.VarProp[string]{Field: "red"}, + } + Expect(w.colour.IsNone()).To(BeFalse()) + }) + }) + + When("string value is unassigned", func() { + It("should: return false without panic", func() { + w := &widget{ + colour: utils.VarProp[string]{}, + } + Expect(w.colour.IsNone()).To(BeTrue()) + }) + }) + }) + + Context("RoRef", func() { + It("should: get read only interface", func() { + w := &widget{ + colour: utils.VarProp[string]{Field: "red"}, + } + RoRef := w.colour.RoRef() + Expect(RoRef.Get()).To(Equal("red")) + + w.colour.Set("blue") + Expect(RoRef.Get()).To(Equal("blue")) + }) + }) + }) + + Context("ConstProp", func() { + Context("Get", func() { + It("should: retrieve property value", func() { + w := &widget{ + colourRo: utils.NewRoProp("red"), + } + Expect(w.colourRo.Get()).To(Equal("red")) + }) + }) + + Context("IsNone", func() { + When("string value is previously defined", func() { + It("should: return false without panic", func() { + w := &widget{ + colourRo: utils.RoPropFactory[string]{}.New("red"), + } + Expect(w.colourRo.IsNone()).To(BeFalse()) + }) + }) + }) + }) + }) + + Context("given: pointer to struct property (with scalar [count])", func() { + Context("VarProp", func() { + Context("Get", func() { + It("should: retrieve property value", func() { + w := &widget{ + slug: utils.VarProp[*slugInfo]{ + Field: &slugInfo{ + count: utils.VarProp[int]{Field: 42}, + }, + }, + } + Expect(w.slug.Get().count.Get()).To(Equal(42)) + }) + }) + + Context("Set", func() { + It("should: set property value", func() { + w := &widget{} + w.slug.Set(&slugInfo{ + count: utils.VarProp[int]{Field: 42}, + }) + Expect(w.slug.Get().count.Get()).To(Equal(42)) + }) + }) + + Context("IsNone", func() { + When("pointer value is previously defined", func() { + It("should: return false without panic", func() { + w := &widget{ + slug: utils.VarProp[*slugInfo]{ + Field: &slugInfo{ + count: utils.VarProp[int]{Field: 42}, + }, + }, + } + Expect(w.slug.IsNone()).To(BeFalse()) + Expect(w.slug.Get().count.IsNone()).To(BeFalse()) + }) + }) + + When("pointer value is unassigned", func() { + It("should: return true without panic", func() { + w := &widget{} + Expect(w.slug.IsNone()).To(BeTrue()) + }) + }) + }) + }) + }) + + Context("given: int property", func() { + Context("PutProp/(putVarProp)", func() { + Context("Get", func() { + It("should: retrieve property value", func() { + factory := utils.PutPropFactory[int]{} + w := &widget{ + rank: factory.New(77, func(_ int) {}), + } + Expect(w.rank.Get()).To(Equal(77)) + }) + }) + + Context("Set", func() { + It("should: set property value", func() { + factory := utils.PutPropFactory[int]{} + w := &widget{ + rank: factory.New(77, func(_ int) {}), + } + w.rank.Set(88) + Expect(w.rank.Get()).To(Equal(88)) + }) + }) + + Context("Put", func() { + It("should: set property value via putter", func() { + factory := utils.PutPropFactory[int]{} + var another int + w := &widget{ + rank: factory.New(77, func(value int) { + another = value + }), + } + w.rank.Put(88) + Expect(another).To(Equal(88)) + }) + }) + + Context("IsNone", func() { + When("int value is previously defined", func() { + It("should: return false without panic", func() { + factory := utils.PutPropFactory[int]{} + w := &widget{ + rank: factory.New(77, func(_ int) {}), + } + Expect(w.rank.IsNone()).To(BeFalse()) + }) + }) + + When("value is explicitly set to it's zero value", func() { + Context("Is Zeroable", func() { + It("should: return false without panic", func() { + factory := utils.PutPropFactory[int]{Zeroable: true} + w := &widget{ + rank: factory.New(0, func(_ int) {}), + } + Expect(w.rank.IsNone()).To(BeFalse()) + }) + }) + + Context("Is NOT Zeroable", func() { + It("should: return false without panic", func() { + factory := utils.PutPropFactory[int]{} + w := &widget{ + rank: factory.New(0, func(_ int) {}), + } + Expect(w.rank.IsNone()).To(BeTrue()) + }) + }) + }) + }) + }) + }) + + Context("given: float64 property", func() { + Context("VarProp", func() { + Context("IsNone", func() { + When("flat64 value is previously defined", func() { + It("should: return false without panic", func() { + w := &widget{ + fraction: utils.NewRwPropZ(float64(0.12345)), + } + Expect(w.fraction.IsNone()).To(BeFalse()) + }) + }) + + When("value is explicitly set to it's zero value", func() { + Context("Is Zeroable", func() { + It("should: return false without panic", func() { + factory := utils.RwPropFactory[float64]{Zeroable: true} + w := &widget{ + fraction: factory.New(float64(0)), + } + Expect(w.fraction.IsNone()).To(BeFalse()) + }) + }) + + Context("Is NOT Zeroable", func() { + It("should: return false without panic", func() { + factory := utils.RwPropFactory[float64]{} + w := &widget{ + fraction: factory.New(float64(0)), + } + Expect(w.fraction.IsNone()).To(BeTrue()) + }) + }) + }) + }) + }) + }) + + Context("given: interface property", func() { + Context("VarProp", func() { + Context("Get", func() { + It("should: retrieve property value", func() { + w := &widget{ + quantity: utils.VarProp[sizeable]{Field: &nugget{66}}, + } + Expect(w.quantity.Get().Measure()).To(Equal(66)) + }) + }) + + Context("Set", func() { + It("should: set property value", func() { + w := &widget{ + quantity: utils.VarProp[sizeable]{Field: &nugget{66}}, + } + w.quantity.Set(&nugget{99}) + Expect(w.quantity.Get().Measure()).To(Equal(99)) + }) + }) + + Context("IsNone", func() { + When("interface value is previously defined", func() { + It("should: return false without panic", func() { + w := &widget{ + quantity: utils.VarProp[sizeable]{Field: &nugget{66}}, + } + Expect(w.quantity.IsNone()).To(BeFalse()) + }) + }) + + When("pointer value is unassigned", func() { + It("should: return true without panic", func() { + w := &widget{} + Expect(w.quantity.IsNone()).To(BeTrue()) + }) + }) + }) + }) + }) + + Context("given: struct property", func() { + Context("VarProp", func() { + Context("Get", func() { + It("should: retrieve copy of property value", func() { + w := &widget{ + gold: utils.VarProp[nugget]{Field: nugget{66}}, + } + clone := w.gold.Get() + Expect(clone.size).To(Equal(66)) + + clone.size = 42 + Expect(w.gold.Get().size).To(Equal(66)) + }) + }) + + Context("Set", func() { + It("should: set struct property value", func() { + w := &widget{ + gold: utils.VarProp[nugget]{}, + } + w.gold.Set(nugget{66}) + Expect(w.gold.Get().size).To(Equal(66)) + }) + }) + + Context("IsNone", func() { + When("struct value is previously defined", func() { + It("should: return false without panic", func() { + w := &widget{ + gold: utils.VarProp[nugget]{Field: nugget{66}}, + } + Expect(w.gold.IsNone()).To(BeFalse()) + }) + }) + + When("struct value is unassigned", func() { + It("should: return true without panic", func() { + w := &widget{} + Expect(w.slug.IsNone()).To(BeTrue()) + }) + }) + }) + }) + }) + + Context("given: function property", func() { + Context("VarProp", func() { + Context("Get", func() { + It("should: retrieve function property value", func() { + w := &widget{ + fn: utils.VarProp[fnProperty]{Field: pi}, + } + Expect(w.fn.Get()()).To(Equal(pi5dp)) + }) + }) + + Context("Set", func() { + It("should: set function property value", func() { + w := &widget{ + fn: utils.VarProp[fnProperty]{}, + } + w.fn.Set(pi) + Expect(w.fn.Get()()).To(Equal(pi5dp)) + }) + }) + + Context("IsNone", func() { + When("struct value is previously defined", func() { + It("should: return false without panic", func() { + w := &widget{ + fn: utils.VarProp[fnProperty]{Field: pi}, + } + Expect(w.fn.IsNone()).To(BeFalse()) + }) + }) + + When("struct value is unassigned", func() { + It("should: return true without panic", func() { + w := &widget{} + Expect(w.fn.IsNone()).To(BeTrue()) + }) + }) + }) + }) + }) + + Context("given: slice property", func() { + Context("VarProp", func() { + Context("Get", func() { + It("should: retrieve slice property value", func() { + w := &widget{ + points: utils.VarProp[slice]{Field: slice{2, 4, 6, 8}}, + } + Expect(w.points.Get()).To(Equal(slice{2, 4, 6, 8})) + }) + }) + + Context("Set", func() { + It("should: set slice property value", func() { + w := &widget{} + w.points.Set(slice{2, 4, 6, 8}) + Expect(w.points.Get()).To(Equal(slice{2, 4, 6, 8})) + }) + }) + + Context("IsNone", func() { + When("slice value is previously defined", func() { + It("should: return false without panic", func() { + w := &widget{ + points: utils.VarProp[slice]{Field: slice{2, 4, 6, 8}}, + } + Expect(w.points.IsNone()).To(BeFalse()) + }) + }) + + When("slice value is unassigned", func() { + It("should: return true without panic", func() { + w := &widget{} + Expect(w.points.IsNone()).To(BeTrue()) + }) + }) + }) + }) + }) + + Context("given: map property", func() { + Context("VarProp", func() { + Context("Get", func() { + It("should: retrieve map property value", func() { + w := &widget{ + numbers: utils.VarProp[dictionary]{Field: evens}, + } + Expect(w.numbers.Get()[2]).To(Equal("Two")) + }) + }) + + Context("Set", func() { + It("should: set map property value", func() { + w := &widget{} + w.numbers.Set(evens) + // check equivalence, not identity + // + Expect(w.numbers.Get()).To(Equal(dictionary{ + 2: "Two", + 4: "Four", + 6: "Six", + })) + }) + }) + + Context("IsNone", func() { + When("slice value is previously defined", func() { + It("should: return false without panic", func() { + w := &widget{ + numbers: utils.VarProp[dictionary]{Field: evens}, + } + Expect(w.numbers.IsNone()).To(BeFalse()) + }) + }) + + When("slice value is unassigned", func() { + It("should: return true without panic", func() { + w := &widget{} + Expect(w.numbers.IsNone()).To(BeTrue()) + }) + }) + }) + }) + }) + + Context("given: channel property", func() { + Context("VarProp", func() { + Context("Get", func() { + It("should: retrieve channel property value", func() { + w := &widget{ + tunnel: utils.VarProp[intChan]{Field: make(chan int)}, + } + Expect(w.tunnel.Get()).NotTo(BeNil()) + }) + }) + + Context("Set", func() { + It("should: set map property value", func() { + w := &widget{} + w.tunnel.Set(make(chan int)) + Expect(w.tunnel.Get()).NotTo(BeNil()) + }) + }) + + Context("IsNone", func() { + When("channel value is previously defined", func() { + It("should: return false without panic", func() { + w := &widget{ + tunnel: utils.VarProp[intChan]{Field: make(chan int)}, + } + Expect(w.tunnel.IsNone()).To(BeFalse()) + }) + }) + + When("channel value is unassigned", func() { + It("should: return true without panic", func() { + w := &widget{} + Expect(w.tunnel.IsNone()).To(BeTrue()) + }) + }) + }) + }) + }) +}) diff --git a/utils/must.go b/utils/must.go new file mode 100644 index 0000000..88784d0 --- /dev/null +++ b/utils/must.go @@ -0,0 +1,8 @@ +package utils + +// Must +func Must(err error) { + if err != nil { + panic(err) + } +} diff --git a/utils/resolve-path.go b/utils/resolve-path.go new file mode 100644 index 0000000..b9c1763 --- /dev/null +++ b/utils/resolve-path.go @@ -0,0 +1,83 @@ +package utils + +import ( + "os" + "path/filepath" + + "github.com/snivilised/li18ngo/internal/lo" +) + +// AbsFunc signature of function used to obtain the absolute representation of +// a path. +type AbsFunc func(path string) (string, error) + +// Abs function invoker, allows a function to be used in place where +// an instance of an interface would be expected. +func (f AbsFunc) Abs(path string) (string, error) { + return f(path) +} + +// HomeUserFunc signature of function used to obtain the user's home directory. +type HomeUserFunc func() (string, error) + +// Home function invoker, allows a function to be used in place where +// an instance of an interface would be expected. +func (f HomeUserFunc) Home() (string, error) { + return f() +} + +// ResolveMocks, used to override the internal functions used +// to resolve the home path (os.UserHomeDir) and the abs path +// (filepath.Abs). In normal usage, these do not need to be provided, +// just used for testing purposes. +type ResolveMocks struct { + HomeFunc HomeUserFunc + AbsFunc AbsFunc +} + +// ResolvePath performs 2 forms of path resolution. The first is resolving a +// home path reference, via the ~ character; ~ is replaced by the user's +// home path. The second resolves ./ or ../ relative path. The overrides +// do not need to be provided. +func ResolvePath(path string, mocks ...ResolveMocks) string { + result := path + + if len(mocks) > 0 { + m := mocks[0] + result = lo.TernaryF(result[0] == '~', + func() string { + if h, err := m.HomeFunc(); err == nil { + return filepath.Join(h, result[1:]) + } + + return path + }, + func() string { + if a, err := m.AbsFunc(result); err == nil { + return a + } + + return path + }, + ) + } else { + result = lo.TernaryF(result[0] == '~', + func() string { + if h, err := os.UserHomeDir(); err == nil { + return filepath.Join(h, result[1:]) + } + + return path + }, + func() string { + if a, err := filepath.Abs(result); err == nil { + return a + } + + return path + }, + ) + } + + return result +} diff --git a/utils/resolve-path_test.go b/utils/resolve-path_test.go new file mode 100644 index 0000000..1e29876 --- /dev/null +++ b/utils/resolve-path_test.go @@ -0,0 +1,121 @@ +package utils_test + +import ( + "fmt" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" //nolint:revive // ok + . "github.com/onsi/gomega" //nolint:revive // ok + + "github.com/snivilised/li18ngo/utils" +) + +type RPEntry struct { + given string + should string + path string + expect string +} + +var fakeHome = filepath.Join(string(filepath.Separator), "home", "rabbitweed") +var fakeAbsCwd = filepath.Join(string(filepath.Separator), "home", "rabbitweed", "music", "xpander") +var fakeAbsParent = filepath.Join(string(filepath.Separator), "home", "rabbitweed", "music") + +func fakeHomeResolver() (string, error) { + return fakeHome, nil +} + +func fakeAbsResolver(path string) (string, error) { + if strings.HasPrefix(path, "..") { + return filepath.Join(fakeAbsParent, path[2:]), nil + } + + if strings.HasPrefix(path, ".") { + return filepath.Join(fakeAbsCwd, path[1:]), nil + } + + return path, nil +} + +var _ = Describe("ResolvePath", func() { + DescribeTable("Overrides provided", + func(entry *RPEntry) { + mocks := utils.ResolveMocks{ + HomeFunc: fakeHomeResolver, + AbsFunc: fakeAbsResolver, + } + + if filepath.Separator == '/' { + actual := utils.ResolvePath(entry.path, mocks) + Expect(actual).To(Equal(entry.expect)) + } else { + normalisedPath := strings.ReplaceAll(entry.path, "/", string(filepath.Separator)) + normalisedExpect := strings.ReplaceAll(entry.expect, "/", string(filepath.Separator)) + + actual := utils.ResolvePath(normalisedPath, mocks) + Expect(actual).To(Equal(normalisedExpect)) + } + }, + func(entry *RPEntry) string { + return fmt.Sprintf("๐Ÿงช ===> given: '%v', should: '%v'", entry.given, entry.should) + }, + + Entry(nil, &RPEntry{ + given: "path is a valid absolute path", + should: "return path unmodified", + path: "/home/rabbitweed/foo", + expect: "/home/rabbitweed/foo", + }), + Entry(nil, &RPEntry{ + given: "path contains leading ~", + should: "replace ~ with home path", + path: "~/foo", + expect: "/home/rabbitweed/foo", + }), + Entry(nil, &RPEntry{ + given: "path is relative to cwd", + should: "replace ~ with home path", + path: "./foo", + expect: "/home/rabbitweed/music/xpander/foo", + }), + Entry(nil, &RPEntry{ + given: "path is relative to parent", + should: "replace ~ with home path", + path: "../foo", + expect: "/home/rabbitweed/music/foo", + }), + Entry(nil, &RPEntry{ + given: "path is relative to grand parent", + should: "replace ~ with home path", + path: "../../foo", + expect: "/home/rabbitweed/foo", + }), + ) + + When("No overrides provided", func() { + Context("and: home", func() { + It("๐Ÿงช should: not fail", func() { + utils.ResolvePath("~/") + }) + }) + + Context("and: abs cwd", func() { + It("๐Ÿงช should: not fail", func() { + utils.ResolvePath("./") + }) + }) + + Context("and: abs parent", func() { + It("๐Ÿงช should: not fail", func() { + utils.ResolvePath("../") + }) + }) + + Context("and: abs grand parent", func() { + It("๐Ÿงช should: not fail", func() { + utils.ResolvePath("../..") + }) + }) + }) +}) diff --git a/utils/spilt-parent.go b/utils/spilt-parent.go new file mode 100644 index 0000000..4df8c66 --- /dev/null +++ b/utils/spilt-parent.go @@ -0,0 +1,12 @@ +package utils + +import ( + "path/filepath" +) + +func SplitParent(path string) (d, f string) { + d = filepath.Dir(path) + f = filepath.Base(path) + + return d, f +} diff --git a/utils/utils_suite_test.go b/utils/utils_suite_test.go new file mode 100644 index 0000000..c516a19 --- /dev/null +++ b/utils/utils_suite_test.go @@ -0,0 +1,13 @@ +package utils_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" //nolint:revive // ok + . "github.com/onsi/gomega" //nolint:revive // ok +) + +func TestUtils(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Utils Suite") +}