From cf638ac0804fae6801d3cdeeb8fdfc3ecd77a9e2 Mon Sep 17 00:00:00 2001 From: Saad Date: Fri, 6 Nov 2026 14:31:49 +0600 Subject: [PATCH 01/29] chore: gitignore /tmp for storing temporary data --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 3708dea41..f02f5ca7d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ .vscode /vendor coverage.out -.opencode \ No newline at end of file +.opencode +/tmp From 109af1d602a4c25370bc5dc63f32bf0609b8ec97 Mon Sep 17 00:00:00 2001 From: Saad Date: Fri, 6 Nov 2026 15:05:26 +0600 Subject: [PATCH 02/29] feat(support): add path.CleanRelative --- support/path/path.go | 22 ++++++++++++++++++++++ support/path/path_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 support/path/path_test.go diff --git a/support/path/path.go b/support/path/path.go index 90d921d42..0963fc751 100644 --- a/support/path/path.go +++ b/support/path/path.go @@ -1,6 +1,10 @@ package path import ( + stdpath "path" + "path/filepath" + "strings" + "github.com/goravel/framework/packages" packagespaths "github.com/goravel/framework/packages/paths" ) @@ -17,6 +21,24 @@ func Bootstrap(paths ...string) string { return packages.Paths().Bootstrap().Abs(paths...) } +// CleanRelative cleans p as a slash-separated path relative to some root and +// reports whether it stays inside that root. "a/./b" and "a/../b" become "a/b" +// and "b"; an absolute path, ".." and anything starting with "../" are refused. +// "" and "." clean to ".", the root itself. +func CleanRelative(p string) (string, bool) { + p = filepath.ToSlash(p) + if stdpath.IsAbs(p) || filepath.IsAbs(filepath.FromSlash(p)) { + return "", false + } + + cleaned := stdpath.Clean(p) + if cleaned == ".." || strings.HasPrefix(cleaned, "../") { + return "", false + } + + return cleaned, true +} + func Config(paths ...string) string { return packages.Paths().Config().Abs(paths...) } diff --git a/support/path/path_test.go b/support/path/path_test.go new file mode 100644 index 000000000..8544b8d2b --- /dev/null +++ b/support/path/path_test.go @@ -0,0 +1,38 @@ +package path + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCleanRelative(t *testing.T) { + tests := []struct { + name string + path string + expected string + ok bool + }{ + {name: "empty is the root", path: "", expected: ".", ok: true}, + {name: "dot is the root", path: ".", expected: ".", ok: true}, + {name: "plain path", path: "public", expected: "public", ok: true}, + {name: "nested path", path: "resources/assets", expected: "resources/assets", ok: true}, + {name: "dot segment", path: "a/./b", expected: "a/b", ok: true}, + {name: "parent segment inside the root", path: "a/../b", expected: "b", ok: true}, + {name: "leaves a subdirectory, stays inside", path: "resources/js/../assets/x.jpg", expected: "resources/assets/x.jpg", ok: true}, + {name: "trailing slash", path: "public/", expected: "public", ok: true}, + {name: "parent", path: "..", ok: false}, + {name: "starts with parent", path: "../shop", ok: false}, + {name: "escapes after cleaning", path: "a/../../x", ok: false}, + {name: "deep escape", path: "modules/blog/resources/js/../../../../../.env", ok: false}, + {name: "absolute", path: "/etc/passwd", ok: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleaned, ok := CleanRelative(tt.path) + assert.Equal(t, tt.ok, ok) + assert.Equal(t, tt.expected, cleaned) + }) + } +} From 3e5a79ae50710f7d0160679fd1c56dfbb227a3f8 Mon Sep 17 00:00:00 2001 From: Saad Date: Fri, 6 Nov 2026 15:11:17 +0600 Subject: [PATCH 03/29] fix(foundation): key service providers by their full type path --- foundation/provider_repository.go | 27 ++++++++++++++++-- foundation/provider_repository_test.go | 39 ++++++++++++++++++++------ 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/foundation/provider_repository.go b/foundation/provider_repository.go index b2f2d9404..c176b9a45 100644 --- a/foundation/provider_repository.go +++ b/foundation/provider_repository.go @@ -3,6 +3,7 @@ package foundation import ( "fmt" "maps" + "reflect" "slices" "strings" @@ -41,7 +42,7 @@ func (r *ProviderRepository) Add(providers []foundation.ServiceProvider) { } for _, provider := range providers { - key := r.getProviderName(provider) + key := r.providerKey(provider) if _, exists := r.states[key]; exists { continue @@ -60,7 +61,7 @@ func (r *ProviderRepository) Boot(app foundation.Application) { providers := r.getProviders() for _, provider := range providers { - key := r.getProviderName(provider) + key := r.providerKey(provider) state, exists := r.states[key] if !exists || !state.registered || state.booted { @@ -107,7 +108,7 @@ func (r *ProviderRepository) Register(app foundation.Application) []foundation.S processed := make([]foundation.ServiceProvider, 0, len(providers)) for _, provider := range providers { - key := r.getProviderName(provider) + key := r.providerKey(provider) state, exists := r.states[key] if !exists { state = &ProviderState{provider: provider} @@ -152,10 +153,30 @@ func (r *ProviderRepository) getRelationship(provider foundation.ServiceProvider return binding.Relationship{} } +// getProviderName is the short type name of a provider, e.g. "*console.ServiceProvider", +// used in messages. It is not unique: providers are keyed by providerKey. func (r *ProviderRepository) getProviderName(provider foundation.ServiceProvider) string { return fmt.Sprintf("%T", provider) } +// providerKey identifies a provider by its full type path, e.g. +// "*github.com/goravel/framework/console.ServiceProvider", so two packages that +// both name their provider ServiceProvider do not collide and drop one another. +// Unnamed types (no package path) fall back to the %T form. +func (r *ProviderRepository) providerKey(provider foundation.ServiceProvider) string { + t := reflect.TypeOf(provider) + prefix := "" + for t.Kind() == reflect.Pointer { + prefix += "*" + t = t.Elem() + } + if t.PkgPath() == "" || t.Name() == "" { + return fmt.Sprintf("%T", provider) + } + + return prefix + t.PkgPath() + "." + t.Name() +} + // sort performs a topological sort on a list of providers to ensure // providers with dependencies are registered and booted *after* // the providers they depend on. diff --git a/foundation/provider_repository_test.go b/foundation/provider_repository_test.go index bcf6df895..e46870f00 100644 --- a/foundation/provider_repository_test.go +++ b/foundation/provider_repository_test.go @@ -35,11 +35,32 @@ func (s *ProviderRepositoryTestSuite) TestAdd() { s.Equal(providers, s.repository.providers) s.Len(s.repository.states, 2) - s.Contains(s.repository.states, s.repository.getProviderName(providerA)) - s.Contains(s.repository.states, s.repository.getProviderName(providerB)) + s.Contains(s.repository.states, s.repository.providerKey(providerA)) + s.Contains(s.repository.states, s.repository.providerKey(providerB)) s.False(s.repository.sortedValid) } +// ServiceProvider shares its short type name, *foundation.ServiceProvider, with the +// generated mock in mocks/foundation. +type ServiceProvider struct{} + +func (r *ServiceProvider) Register(foundation.Application) {} +func (r *ServiceProvider) Boot(foundation.Application) {} + +func (s *ProviderRepositoryTestSuite) TestAdd_SameTypeNameInDifferentPackages() { + // Both are *foundation.ServiceProvider by %T; each must keep its own state. + localProvider := &ServiceProvider{} + mockProvider := mocksfoundation.NewServiceProvider(s.T()) + s.Require().Equal(s.repository.getProviderName(localProvider), s.repository.getProviderName(mockProvider)) + + s.repository.Add([]foundation.ServiceProvider{localProvider, mockProvider}) + + s.Len(s.repository.providers, 2) + s.Len(s.repository.states, 2) + s.Equal("*github.com/goravel/framework/foundation.ServiceProvider", s.repository.providerKey(localProvider)) + s.Equal("*github.com/goravel/framework/mocks/foundation.ServiceProvider", s.repository.providerKey(mockProvider)) +} + func (s *ProviderRepositoryTestSuite) TestAdd_Duplicates() { providerA := &AServiceProvider{} @@ -63,7 +84,7 @@ func (s *ProviderRepositoryTestSuite) TestBoot() { mockProvider.EXPECT().Boot(s.mockApp).Return().Once() s.repository.Boot(s.mockApp) - s.True(s.repository.states[s.repository.getProviderName(mockProvider)].booted) + s.True(s.repository.states[s.repository.providerKey(mockProvider)].booted) } func (s *ProviderRepositoryTestSuite) TestBoot_Idempotency() { @@ -78,7 +99,7 @@ func (s *ProviderRepositoryTestSuite) TestBoot_Idempotency() { s.repository.Boot(s.mockApp) s.repository.Boot(s.mockApp) - s.True(s.repository.states[s.repository.getProviderName(mockProvider)].booted) + s.True(s.repository.states[s.repository.providerKey(mockProvider)].booted) mockProvider.AssertExpectations(s.T()) } @@ -88,8 +109,8 @@ func (s *ProviderRepositoryTestSuite) TestGetBooted() { s.repository.Add([]foundation.ServiceProvider{providerA, providerB}) - keyA := s.repository.getProviderName(providerA) - keyB := s.repository.getProviderName(providerB) + keyA := s.repository.providerKey(providerA) + keyB := s.repository.providerKey(providerB) s.NotEqual(keyA, keyB) @@ -162,8 +183,8 @@ func (s *ProviderRepositoryTestSuite) TestRegister() { processed := s.repository.Register(s.mockApp) s.Equal([]foundation.ServiceProvider{mockProvider}, processed) - s.True(s.repository.states[s.repository.getProviderName(mockProvider)].registered) - s.False(s.repository.states[s.repository.getProviderName(mockProvider)].booted) + s.True(s.repository.states[s.repository.providerKey(mockProvider)].registered) + s.False(s.repository.states[s.repository.providerKey(mockProvider)].booted) } func (s *ProviderRepositoryTestSuite) TestRegister_Idempotency() { @@ -175,7 +196,7 @@ func (s *ProviderRepositoryTestSuite) TestRegister_Idempotency() { s.repository.Register(s.mockApp) s.repository.Register(s.mockApp) - s.True(s.repository.states[s.repository.getProviderName(mockProvider)].registered) + s.True(s.repository.states[s.repository.providerKey(mockProvider)].registered) mockProvider.AssertExpectations(s.T()) } From 95f8357160129478356c19b533630a5fdbf5a5df Mon Sep 17 00:00:00 2001 From: Saad Date: Fri, 6 Nov 2026 21:10:52 +0600 Subject: [PATCH 04/29] feat(modular): add the module contracts and registry --- contracts/foundation/module.go | 7 ++ contracts/modular/modular.go | 138 ++++++++++++++++++++++++++++ errors/list.go | 4 + errors/modules.go | 1 + modular/path.go | 66 ++++++++++++++ modular/path_test.go | 81 +++++++++++++++++ modular/registry.go | 160 +++++++++++++++++++++++++++++++++ modular/registry_test.go | 128 ++++++++++++++++++++++++++ 8 files changed, 585 insertions(+) create mode 100644 contracts/foundation/module.go create mode 100644 contracts/modular/modular.go create mode 100644 modular/path.go create mode 100644 modular/path_test.go create mode 100644 modular/registry.go create mode 100644 modular/registry_test.go diff --git a/contracts/foundation/module.go b/contracts/foundation/module.go new file mode 100644 index 000000000..5bc761883 --- /dev/null +++ b/contracts/foundation/module.go @@ -0,0 +1,7 @@ +package foundation + +// ProvidesServices is a module capability, declared here to keep contracts/modular +// free of this package. +type ProvidesServices interface { + ServiceProviders() []ServiceProvider +} diff --git a/contracts/modular/modular.go b/contracts/modular/modular.go new file mode 100644 index 000000000..790056a4d --- /dev/null +++ b/contracts/modular/modular.go @@ -0,0 +1,138 @@ +package modular + +import ( + "io/fs" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/database/schema" + "github.com/goravel/framework/contracts/database/seeder" + "github.com/goravel/framework/contracts/event" + "github.com/goravel/framework/contracts/http" + "github.com/goravel/framework/contracts/queue" + "github.com/goravel/framework/contracts/route" + "github.com/goravel/framework/contracts/schedule" +) + +// Module is an application feature in one Go package. Its capabilities are the +// optional interfaces below it implements; providers come from +// foundation.ProvidesServices. +type Module interface { + // Name is unique and kebab-case, e.g. "blog": views "blog/...", translation + // groups "blog/...", config "modules.blog", Vite alias "@blog". + Name() string +} + +// Located sets the module directory, relative to the project root. Without it, +// the directory is derived from the module's Go package (see Registry.Register). +type Located interface { + Path() string +} + +// Ordered sets the module order: lower first, negative last, ties by registration. +// The default is 1. +type Ordered interface { + Order() int +} + +// ProvidesConfig contributes the config stored under "modules.". +type ProvidesConfig interface { + Config() map[string]any +} + +// ProvidesGlobalMiddleware contributes global HTTP middleware, added before WithMiddleware runs. +type ProvidesGlobalMiddleware interface { + GlobalMiddleware() []http.Middleware +} + +// ProvidesRoutes registers web routes in the modular.routes.web group. +type ProvidesRoutes interface { + Routes(router route.Router) +} + +// ProvidesApiRoutes registers API routes in the modular.routes.api group. +type ProvidesApiRoutes interface { + ApiRoutes(router route.Router) +} + +// ProvidesMigrations contributes database migrations. +type ProvidesMigrations interface { + Migrations() []schema.Migration +} + +// ProvidesSeeders contributes database seeders. +type ProvidesSeeders interface { + Seeders() []seeder.Seeder +} + +// ProvidesCommands contributes Artisan commands. +type ProvidesCommands interface { + Commands() []console.Command +} + +// ProvidesJobs contributes queue jobs. +type ProvidesJobs interface { + Jobs() []queue.Job +} + +// ProvidesEvents contributes listeners, merged per event with WithEvents. +type ProvidesEvents interface { + Events() map[event.Event][]event.Listener +} + +// ProvidesSchedule contributes scheduled events. +type ProvidesSchedule interface { + Schedule() []schedule.Event +} + +// ProvidesViews contributes views: "index.tmpl" becomes "/index.tmpl". +type ProvidesViews interface { + Views() fs.FS +} + +// ProvidesTranslations contributes translations: "en/messages.json" is the group +// "/messages". The "*" group (en.json) is not supported. +type ProvidesTranslations interface { + Translations() fs.FS +} + +// ProvidesViteEntries lists Vite entries relative to the module directory, built +// even when no view references them. +type ProvidesViteEntries interface { + ViteEntries() []string +} + +// ProvidesViteAlias contributes Vite aliases, e.g. "@blog-assets" -> "resources/assets" +// (relative to the module directory). Its own "@" key replaces the default. +type ProvidesViteAlias interface { + ViteAliases() map[string]string +} + +// ProvidesStatic serves files as-is: URL prefix -> Path. +type ProvidesStatic interface { + Static() map[string]Path +} + +// Path is a static directory: Dir inside FS, or on disk relative to the module +// directory when FS is nil. Dir must stay inside the module. +type Path struct { + FS fs.FS + Dir string +} + +// Registry holds the application's modules. +type Registry interface { + // Register adds modules and resolves their directories: Located, else the + // package path inside the app, else "/" for another Go + // module. Duplicate names and unresolvable directories are errors. + Register(modules ...Module) error + // All returns the modules sorted by Order. + All() []Module + // Find returns the module named name. + Find(name string) (Module, bool) + // Has reports whether name is registered. + Has(name string) bool + // Path returns a module's directory relative to the project root. + Path(name string) (string, bool) + // Capabilities reports which optional interfaces the module implements, by label. + Capabilities(module Module) map[string]bool +} diff --git a/errors/list.go b/errors/list.go index 60b5dfc14..4f9260f98 100644 --- a/errors/list.go +++ b/errors/list.go @@ -217,6 +217,10 @@ var ( MigrationResetFailed = New("migration reset failed: %v") MigrationRollbackFailed = New("migration rollback failed: %v") + ModularModuleDuplicate = New("module %q is registered more than once").SetModule(ModuleModular) + ModularModulePathInvalid = New("module %s: directory %q must be a relative path inside the project").SetModule(ModuleModular) + ModularModulePathUnknown = New("module %s: cannot derive its directory from package %q, implement Path() to set it").SetModule(ModuleModular) + NotificationChannelNotFound = New("notification channel not found: %s").SetModule(ModuleNotification) NotificationChannelNotQueueable = New("notification channel %q does not support queued dispatch (does not implement ResolvableChannel)").SetModule(ModuleNotification) NotificationInvalidQueuePayload = New("notification queue payload is missing or malformed").SetModule(ModuleNotification) diff --git a/errors/modules.go b/errors/modules.go index 97574bbba..958eed871 100644 --- a/errors/modules.go +++ b/errors/modules.go @@ -17,6 +17,7 @@ var ( ModuleLog = "log" ModuleMail = "mail" ModuleMigration = "migration" + ModuleModular = "modular" ModuleNotification = "notification" ModuleOrm = "orm" ModulePackages = "packages" diff --git a/modular/path.go b/modular/path.go new file mode 100644 index 000000000..b7c2b13b2 --- /dev/null +++ b/modular/path.go @@ -0,0 +1,66 @@ +package modular + +import ( + stdpath "path" + "reflect" + "strings" + + "github.com/goravel/framework/contracts/modular" + "github.com/goravel/framework/errors" + "github.com/goravel/framework/support/path" +) + +// DefaultDirectory holds modules, including published third-party ones. +const DefaultDirectory = "modules" + +// ResolvePath returns the module directory relative to the project root: Located, +// else the package path relative to mainModule, else "/" for a +// package of another Go module. +func ResolvePath(module modular.Module, mainModule, directory string) (string, error) { + name := module.Name() + if located, ok := module.(modular.Located); ok { + dir, ok := path.CleanRelative(located.Path()) + if !ok || strings.TrimSpace(located.Path()) == "" { + return "", errors.ModularModulePathInvalid.Args(name, located.Path()) + } + + return dir, nil + } + + return derivePath(name, packagePath(module), mainModule, directory) +} + +func derivePath(name, pkg, mainModule, directory string) (string, error) { + if pkg == "" || pkg == "main" || mainModule == "" { + return "", errors.ModularModulePathUnknown.Args(name, pkg) + } + + if pkg == mainModule { + return ".", nil + } + if rel, ok := strings.CutPrefix(pkg, mainModule+"/"); ok { + return rel, nil + } + + // Another Go module: one element under directory. + base, ok := path.CleanRelative(directory) + if !ok { + return "", errors.ModularModulePathInvalid.Args(name, directory) + } + dir := stdpath.Join(base, name) + if stdpath.Dir(dir) != base || stdpath.Base(dir) != name { + return "", errors.ModularModulePathInvalid.Args(name, dir) + } + + return dir, nil +} + +// packagePath is the import path of module's type, "" when unnamed. +func packagePath(module modular.Module) string { + t := reflect.TypeOf(module) + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + + return t.PkgPath() +} diff --git a/modular/path_test.go b/modular/path_test.go new file mode 100644 index 000000000..e9857ff82 --- /dev/null +++ b/modular/path_test.go @@ -0,0 +1,81 @@ +package modular + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/goravel/framework/errors" +) + +func TestResolvePath(t *testing.T) { + t.Run("a package inside the application is its directory", func(t *testing.T) { + dir, err := ResolvePath(&plainModule{"blog"}, "github.com/goravel/framework", DefaultDirectory) + require.NoError(t, err) + assert.Equal(t, "modular", dir) + }) + + t.Run("a package of another Go module goes under the module directory", func(t *testing.T) { + dir, err := ResolvePath(&plainModule{"blog"}, "example.com/app", DefaultDirectory) + require.NoError(t, err) + assert.Equal(t, "modules/blog", dir) + }) + + t.Run("Located wins and is cleaned", func(t *testing.T) { + for given, expected := range map[string]string{ + "modules/shop": "modules/shop", + "modules/shop/": "modules/shop", + "modules/../app/shop": "app/shop", + "./packages/shop/../x/": "packages/x", + } { + dir, err := ResolvePath(&locatedModule{plainModule{"shop"}, given}, "", DefaultDirectory) + require.NoError(t, err, given) + assert.Equal(t, expected, dir, given) + } + }) + + t.Run("Located outside the project is refused", func(t *testing.T) { + for _, given := range []string{"", " ", "..", "../shop", "modules/../../shop", "/srv/shop"} { + _, err := ResolvePath(&locatedModule{plainModule{"shop"}, given}, "example.com/app", DefaultDirectory) + assert.ErrorIs(t, err, errors.ModularModulePathInvalid, given) + } + }) +} + +func TestDerivePath(t *testing.T) { + const main = "example.com/app" + + tests := []struct { + name, pkg, mainModule, directory, expected string + err error + }{ + {"blog", "example.com/app/modules/blog", main, "modules", "modules/blog", nil}, + {"blog", "example.com/app/features/blog-posts", main, "modules", "features/blog-posts", nil}, + {"root", "example.com/app", main, "modules", ".", nil}, + {"billing", "github.com/acme/billing", main, "modules", "modules/billing", nil}, + {"billing", "github.com/acme/billing/module", main, "app/modules/", "app/modules/billing", nil}, + // A prefix match must end at a path separator. + {"billing", "example.com/application/billing", main, "modules", "modules/billing", nil}, + {"blog", "main", main, "modules", "", errors.ModularModulePathUnknown}, + {"blog", "", main, "modules", "", errors.ModularModulePathUnknown}, + {"blog", "example.com/app/blog", "", "modules", "", errors.ModularModulePathUnknown}, + {"a/b", "github.com/acme/billing", main, "modules", "", errors.ModularModulePathInvalid}, + {"..", "github.com/acme/billing", main, "modules", "", errors.ModularModulePathInvalid}, + {"", "github.com/acme/billing", main, "modules", "", errors.ModularModulePathInvalid}, + {"billing", "github.com/acme/billing", main, "../modules", "", errors.ModularModulePathInvalid}, + } + for _, tt := range tests { + dir, err := derivePath(tt.name, tt.pkg, tt.mainModule, tt.directory) + if tt.err != nil { + assert.ErrorIs(t, err, tt.err, tt.pkg) + + continue + } + require.NoError(t, err, tt.pkg) + assert.Equal(t, tt.expected, dir, tt.pkg) + } + + _, err := derivePath("blog", "main", main, "modules") + assert.EqualError(t, err, `[modular] module blog: cannot derive its directory from package "main", implement Path() to set it`) +} diff --git a/modular/registry.go b/modular/registry.go new file mode 100644 index 000000000..bea6e6b7b --- /dev/null +++ b/modular/registry.go @@ -0,0 +1,160 @@ +// Package modular implements the module registry and module filesystems. It +// imports no framework implementation, so foundation can import it. +package modular + +import ( + "slices" + + "github.com/goravel/framework/contracts/foundation" + "github.com/goravel/framework/contracts/modular" + "github.com/goravel/framework/errors" + "github.com/goravel/framework/support/env" +) + +var _ modular.Registry = (*Registry)(nil) + +// Registry keeps modules in registration order. +type Registry struct { + modules []modular.Module + byName map[string]modular.Module + paths map[string]string + + // mainModule is the app's Go module path; directory holds third-party modules. + mainModule string + directory string +} + +// NewRegistry places third-party modules under directory (modular.path), "" for +// DefaultDirectory. +func NewRegistry(directory string) *Registry { + if directory == "" { + directory = DefaultDirectory + } + + return &Registry{ + byName: make(map[string]modular.Module), + paths: make(map[string]string), + mainModule: env.MainPath(), + directory: directory, + } +} + +// Register stops at the first error; earlier modules stay registered. +func (r *Registry) Register(modules ...modular.Module) error { + for _, module := range modules { + if module == nil { + continue + } + + name := module.Name() + if _, ok := r.byName[name]; ok { + return errors.ModularModuleDuplicate.Args(name) + } + + dir, err := ResolvePath(module, r.mainModule, r.directory) + if err != nil { + return err + } + + r.byName[name] = module + r.paths[name] = dir + r.modules = append(r.modules, module) + } + + return nil +} + +// All sorts non-negative orders, then negative ones, ascending; ties keep registration order. +func (r *Registry) All() []modular.Module { + sorted := slices.Clone(r.modules) + slices.SortStableFunc(sorted, func(a, b modular.Module) int { + return compareOrder(order(a), order(b)) + }) + + return sorted +} + +func (r *Registry) Find(name string) (modular.Module, bool) { + module, ok := r.byName[name] + + return module, ok +} + +func (r *Registry) Has(name string) bool { + _, ok := r.byName[name] + + return ok +} + +func (r *Registry) Path(name string) (string, bool) { + dir, ok := r.paths[name] + + return dir, ok +} + +func (r *Registry) Capabilities(module modular.Module) map[string]bool { + return map[string]bool{ + "services": implements[foundation.ProvidesServices](module), + "config": implements[modular.ProvidesConfig](module), + "middleware": implements[modular.ProvidesGlobalMiddleware](module), + "routes": implements[modular.ProvidesRoutes](module), + "api": implements[modular.ProvidesApiRoutes](module), + "migrations": implements[modular.ProvidesMigrations](module), + "seeders": implements[modular.ProvidesSeeders](module), + "commands": implements[modular.ProvidesCommands](module), + "jobs": implements[modular.ProvidesJobs](module), + "events": implements[modular.ProvidesEvents](module), + "schedule": implements[modular.ProvidesSchedule](module), + "views": implements[modular.ProvidesViews](module), + "translations": implements[modular.ProvidesTranslations](module), + "vite-entries": implements[modular.ProvidesViteEntries](module), + "vite-aliases": implements[modular.ProvidesViteAlias](module), + "static": implements[modular.ProvidesStatic](module), + } +} + +// Providing returns the modules implementing T, as T. +func Providing[T any](modules []modular.Module) []T { + var out []T + for _, module := range modules { + if p, ok := module.(T); ok { + out = append(out, p) + } + } + + return out +} + +func implements[T any](module modular.Module) bool { + _, ok := module.(T) + + return ok +} + +func order(module modular.Module) int { + if o, ok := module.(modular.Ordered); ok { + return o.Order() + } + + return 0 +} + +// compareOrder sorts non-negative orders before negative ones, each ascending. +func compareOrder(a, b int) int { + if (a < 0) != (b < 0) { + if a < 0 { + return 1 + } + + return -1 + } + + switch { + case a < b: + return -1 + case a > b: + return 1 + default: + return 0 + } +} diff --git a/modular/registry_test.go b/modular/registry_test.go new file mode 100644 index 000000000..7413632bf --- /dev/null +++ b/modular/registry_test.go @@ -0,0 +1,128 @@ +package modular + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/goravel/framework/contracts/foundation" + "github.com/goravel/framework/contracts/modular" + "github.com/goravel/framework/contracts/route" + "github.com/goravel/framework/errors" +) + +type plainModule struct{ name string } + +func (m *plainModule) Name() string { return m.name } + +type locatedModule struct { + plainModule + path string +} + +func (m *locatedModule) Path() string { return m.path } + +type orderedModule struct { + plainModule + order int +} + +func (m *orderedModule) Order() int { return m.order } + +type routedModule struct{ plainModule } + +func (m *routedModule) Routes(route.Router) {} +func (m *routedModule) ServiceProviders() []foundation.ServiceProvider { return nil } + +func names(modules []modular.Module) []string { + var out []string + for _, m := range modules { + out = append(out, m.Name()) + } + + return out +} + +func TestRegistry_All(t *testing.T) { + registry := NewRegistry("") + require.NoError(t, registry.Register( + &orderedModule{plainModule{"last"}, -1}, + &orderedModule{plainModule{"ten"}, 10}, + &plainModule{"zero-a"}, + &orderedModule{plainModule{"before-last"}, -5}, + &plainModule{"zero-b"}, + )) + + assert.Equal(t, []string{"zero-a", "zero-b", "ten", "before-last", "last"}, names(registry.All())) +} + +func TestRegistry_Register(t *testing.T) { + registry := NewRegistry("") + require.NoError(t, registry.Register(&plainModule{"blog"}, nil)) + + err := registry.Register(&plainModule{"shop"}, &plainModule{"blog"}, &plainModule{"forum"}) + assert.ErrorIs(t, err, errors.ModularModuleDuplicate) + assert.EqualError(t, err, `[modular] module "blog" is registered more than once`) + + assert.Equal(t, []string{"blog", "shop"}, names(registry.All())) +} + +func TestRegistry_Path(t *testing.T) { + registry := NewRegistry("") + require.NoError(t, registry.Register(&plainModule{"blog"}, &locatedModule{plainModule{"shop"}, "app/shop"})) + + // Under go test the main module is the framework. + dir, ok := registry.Path("blog") + assert.True(t, ok) + assert.Equal(t, "modular", dir) + + dir, ok = registry.Path("shop") + assert.True(t, ok) + assert.Equal(t, "app/shop", dir) + + _, ok = registry.Path("forum") + assert.False(t, ok) + + err := registry.Register(&locatedModule{plainModule{"forum"}, "../forum"}, &plainModule{"news"}) + assert.ErrorIs(t, err, errors.ModularModulePathInvalid) + assert.False(t, registry.Has("forum")) + assert.False(t, registry.Has("news")) +} + +func TestRegistry_FindHas(t *testing.T) { + registry := NewRegistry("") + blog := &plainModule{"blog"} + require.NoError(t, registry.Register(blog)) + + found, ok := registry.Find("blog") + assert.True(t, ok) + assert.Same(t, blog, found) + assert.True(t, registry.Has("blog")) + + _, ok = registry.Find("shop") + assert.False(t, ok) + assert.False(t, registry.Has("shop")) +} + +func TestRegistry_Capabilities(t *testing.T) { + registry := NewRegistry("") + + capabilities := registry.Capabilities(&routedModule{plainModule{"blog"}}) + assert.Len(t, capabilities, 16) + for label, ok := range capabilities { + assert.Equal(t, label == "routes" || label == "services", ok, label) + } + + for label, ok := range registry.Capabilities(&plainModule{"plain"}) { + assert.False(t, ok, label) + } +} + +func TestProviding(t *testing.T) { + routed := &routedModule{plainModule{"blog"}} + modules := []modular.Module{&plainModule{"plain"}, routed} + + assert.Equal(t, []modular.ProvidesRoutes{routed}, Providing[modular.ProvidesRoutes](modules)) + assert.Empty(t, Providing[modular.ProvidesViews](modules)) +} From 210d46d748736b8fb82d16337034519e6deb1bbf Mon Sep 17 00:00:00 2001 From: Saad Date: Fri, 6 Nov 2026 21:15:59 +0600 Subject: [PATCH 05/29] mocks: generated modular mocks --- mocks/foundation/ProvidesServices.go | 82 +++++ mocks/modular/Located.go | 77 +++++ mocks/modular/Module.go | 77 +++++ mocks/modular/Ordered.go | 77 +++++ mocks/modular/ProvidesApiRoutes.go | 69 +++++ mocks/modular/ProvidesCommands.go | 82 +++++ mocks/modular/ProvidesConfig.go | 79 +++++ mocks/modular/ProvidesEvents.go | 82 +++++ mocks/modular/ProvidesGlobalMiddleware.go | 82 +++++ mocks/modular/ProvidesJobs.go | 83 +++++ mocks/modular/ProvidesMigrations.go | 83 +++++ mocks/modular/ProvidesRoutes.go | 69 +++++ mocks/modular/ProvidesSchedule.go | 83 +++++ mocks/modular/ProvidesSeeders.go | 83 +++++ mocks/modular/ProvidesStatic.go | 82 +++++ mocks/modular/ProvidesTranslations.go | 83 +++++ mocks/modular/ProvidesViews.go | 83 +++++ mocks/modular/ProvidesViteAlias.go | 79 +++++ mocks/modular/ProvidesViteEntries.go | 79 +++++ mocks/modular/Registry.go | 349 ++++++++++++++++++++++ 20 files changed, 1863 insertions(+) create mode 100644 mocks/foundation/ProvidesServices.go create mode 100644 mocks/modular/Located.go create mode 100644 mocks/modular/Module.go create mode 100644 mocks/modular/Ordered.go create mode 100644 mocks/modular/ProvidesApiRoutes.go create mode 100644 mocks/modular/ProvidesCommands.go create mode 100644 mocks/modular/ProvidesConfig.go create mode 100644 mocks/modular/ProvidesEvents.go create mode 100644 mocks/modular/ProvidesGlobalMiddleware.go create mode 100644 mocks/modular/ProvidesJobs.go create mode 100644 mocks/modular/ProvidesMigrations.go create mode 100644 mocks/modular/ProvidesRoutes.go create mode 100644 mocks/modular/ProvidesSchedule.go create mode 100644 mocks/modular/ProvidesSeeders.go create mode 100644 mocks/modular/ProvidesStatic.go create mode 100644 mocks/modular/ProvidesTranslations.go create mode 100644 mocks/modular/ProvidesViews.go create mode 100644 mocks/modular/ProvidesViteAlias.go create mode 100644 mocks/modular/ProvidesViteEntries.go create mode 100644 mocks/modular/Registry.go diff --git a/mocks/foundation/ProvidesServices.go b/mocks/foundation/ProvidesServices.go new file mode 100644 index 000000000..f0e5ac775 --- /dev/null +++ b/mocks/foundation/ProvidesServices.go @@ -0,0 +1,82 @@ +// Code generated by mockery. DO NOT EDIT. + +package foundation + +import ( + foundation "github.com/goravel/framework/contracts/foundation" + mock "github.com/stretchr/testify/mock" +) + +// ProvidesServices is an autogenerated mock type for the ProvidesServices type +type ProvidesServices struct { + mock.Mock +} + +type ProvidesServices_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesServices) EXPECT() *ProvidesServices_Expecter { + return &ProvidesServices_Expecter{mock: &_m.Mock} +} + +// ServiceProviders provides a mock function with no fields +func (_m *ProvidesServices) ServiceProviders() []foundation.ServiceProvider { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for ServiceProviders") + } + + var r0 []foundation.ServiceProvider + if rf, ok := ret.Get(0).(func() []foundation.ServiceProvider); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]foundation.ServiceProvider) + } + } + + return r0 +} + +// ProvidesServices_ServiceProviders_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ServiceProviders' +type ProvidesServices_ServiceProviders_Call struct { + *mock.Call +} + +// ServiceProviders is a helper method to define mock.On call +func (_e *ProvidesServices_Expecter) ServiceProviders() *ProvidesServices_ServiceProviders_Call { + return &ProvidesServices_ServiceProviders_Call{Call: _e.mock.On("ServiceProviders")} +} + +func (_c *ProvidesServices_ServiceProviders_Call) Run(run func()) *ProvidesServices_ServiceProviders_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesServices_ServiceProviders_Call) Return(_a0 []foundation.ServiceProvider) *ProvidesServices_ServiceProviders_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesServices_ServiceProviders_Call) RunAndReturn(run func() []foundation.ServiceProvider) *ProvidesServices_ServiceProviders_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesServices creates a new instance of ProvidesServices. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesServices(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesServices { + mock := &ProvidesServices{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/Located.go b/mocks/modular/Located.go new file mode 100644 index 000000000..822aa0e50 --- /dev/null +++ b/mocks/modular/Located.go @@ -0,0 +1,77 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import mock "github.com/stretchr/testify/mock" + +// Located is an autogenerated mock type for the Located type +type Located struct { + mock.Mock +} + +type Located_Expecter struct { + mock *mock.Mock +} + +func (_m *Located) EXPECT() *Located_Expecter { + return &Located_Expecter{mock: &_m.Mock} +} + +// Path provides a mock function with no fields +func (_m *Located) Path() string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Path") + } + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// Located_Path_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Path' +type Located_Path_Call struct { + *mock.Call +} + +// Path is a helper method to define mock.On call +func (_e *Located_Expecter) Path() *Located_Path_Call { + return &Located_Path_Call{Call: _e.mock.On("Path")} +} + +func (_c *Located_Path_Call) Run(run func()) *Located_Path_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *Located_Path_Call) Return(_a0 string) *Located_Path_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Located_Path_Call) RunAndReturn(run func() string) *Located_Path_Call { + _c.Call.Return(run) + return _c +} + +// NewLocated creates a new instance of Located. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewLocated(t interface { + mock.TestingT + Cleanup(func()) +}) *Located { + mock := &Located{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/Module.go b/mocks/modular/Module.go new file mode 100644 index 000000000..14c56ae75 --- /dev/null +++ b/mocks/modular/Module.go @@ -0,0 +1,77 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import mock "github.com/stretchr/testify/mock" + +// Module is an autogenerated mock type for the Module type +type Module struct { + mock.Mock +} + +type Module_Expecter struct { + mock *mock.Mock +} + +func (_m *Module) EXPECT() *Module_Expecter { + return &Module_Expecter{mock: &_m.Mock} +} + +// Name provides a mock function with no fields +func (_m *Module) Name() string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Name") + } + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// Module_Name_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Name' +type Module_Name_Call struct { + *mock.Call +} + +// Name is a helper method to define mock.On call +func (_e *Module_Expecter) Name() *Module_Name_Call { + return &Module_Name_Call{Call: _e.mock.On("Name")} +} + +func (_c *Module_Name_Call) Run(run func()) *Module_Name_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *Module_Name_Call) Return(_a0 string) *Module_Name_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Module_Name_Call) RunAndReturn(run func() string) *Module_Name_Call { + _c.Call.Return(run) + return _c +} + +// NewModule creates a new instance of Module. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewModule(t interface { + mock.TestingT + Cleanup(func()) +}) *Module { + mock := &Module{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/Ordered.go b/mocks/modular/Ordered.go new file mode 100644 index 000000000..5c12a1db6 --- /dev/null +++ b/mocks/modular/Ordered.go @@ -0,0 +1,77 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import mock "github.com/stretchr/testify/mock" + +// Ordered is an autogenerated mock type for the Ordered type +type Ordered struct { + mock.Mock +} + +type Ordered_Expecter struct { + mock *mock.Mock +} + +func (_m *Ordered) EXPECT() *Ordered_Expecter { + return &Ordered_Expecter{mock: &_m.Mock} +} + +// Order provides a mock function with no fields +func (_m *Ordered) Order() int { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Order") + } + + var r0 int + if rf, ok := ret.Get(0).(func() int); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(int) + } + + return r0 +} + +// Ordered_Order_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Order' +type Ordered_Order_Call struct { + *mock.Call +} + +// Order is a helper method to define mock.On call +func (_e *Ordered_Expecter) Order() *Ordered_Order_Call { + return &Ordered_Order_Call{Call: _e.mock.On("Order")} +} + +func (_c *Ordered_Order_Call) Run(run func()) *Ordered_Order_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *Ordered_Order_Call) Return(_a0 int) *Ordered_Order_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Ordered_Order_Call) RunAndReturn(run func() int) *Ordered_Order_Call { + _c.Call.Return(run) + return _c +} + +// NewOrdered creates a new instance of Ordered. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewOrdered(t interface { + mock.TestingT + Cleanup(func()) +}) *Ordered { + mock := &Ordered{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesApiRoutes.go b/mocks/modular/ProvidesApiRoutes.go new file mode 100644 index 000000000..ee0860dfb --- /dev/null +++ b/mocks/modular/ProvidesApiRoutes.go @@ -0,0 +1,69 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import ( + mock "github.com/stretchr/testify/mock" + + route "github.com/goravel/framework/contracts/route" +) + +// ProvidesApiRoutes is an autogenerated mock type for the ProvidesApiRoutes type +type ProvidesApiRoutes struct { + mock.Mock +} + +type ProvidesApiRoutes_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesApiRoutes) EXPECT() *ProvidesApiRoutes_Expecter { + return &ProvidesApiRoutes_Expecter{mock: &_m.Mock} +} + +// ApiRoutes provides a mock function with given fields: router +func (_m *ProvidesApiRoutes) ApiRoutes(router route.Router) { + _m.Called(router) +} + +// ProvidesApiRoutes_ApiRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ApiRoutes' +type ProvidesApiRoutes_ApiRoutes_Call struct { + *mock.Call +} + +// ApiRoutes is a helper method to define mock.On call +// - router route.Router +func (_e *ProvidesApiRoutes_Expecter) ApiRoutes(router interface{}) *ProvidesApiRoutes_ApiRoutes_Call { + return &ProvidesApiRoutes_ApiRoutes_Call{Call: _e.mock.On("ApiRoutes", router)} +} + +func (_c *ProvidesApiRoutes_ApiRoutes_Call) Run(run func(router route.Router)) *ProvidesApiRoutes_ApiRoutes_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(route.Router)) + }) + return _c +} + +func (_c *ProvidesApiRoutes_ApiRoutes_Call) Return() *ProvidesApiRoutes_ApiRoutes_Call { + _c.Call.Return() + return _c +} + +func (_c *ProvidesApiRoutes_ApiRoutes_Call) RunAndReturn(run func(route.Router)) *ProvidesApiRoutes_ApiRoutes_Call { + _c.Run(run) + return _c +} + +// NewProvidesApiRoutes creates a new instance of ProvidesApiRoutes. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesApiRoutes(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesApiRoutes { + mock := &ProvidesApiRoutes{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesCommands.go b/mocks/modular/ProvidesCommands.go new file mode 100644 index 000000000..a60d67a27 --- /dev/null +++ b/mocks/modular/ProvidesCommands.go @@ -0,0 +1,82 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import ( + console "github.com/goravel/framework/contracts/console" + mock "github.com/stretchr/testify/mock" +) + +// ProvidesCommands is an autogenerated mock type for the ProvidesCommands type +type ProvidesCommands struct { + mock.Mock +} + +type ProvidesCommands_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesCommands) EXPECT() *ProvidesCommands_Expecter { + return &ProvidesCommands_Expecter{mock: &_m.Mock} +} + +// Commands provides a mock function with no fields +func (_m *ProvidesCommands) Commands() []console.Command { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Commands") + } + + var r0 []console.Command + if rf, ok := ret.Get(0).(func() []console.Command); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]console.Command) + } + } + + return r0 +} + +// ProvidesCommands_Commands_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Commands' +type ProvidesCommands_Commands_Call struct { + *mock.Call +} + +// Commands is a helper method to define mock.On call +func (_e *ProvidesCommands_Expecter) Commands() *ProvidesCommands_Commands_Call { + return &ProvidesCommands_Commands_Call{Call: _e.mock.On("Commands")} +} + +func (_c *ProvidesCommands_Commands_Call) Run(run func()) *ProvidesCommands_Commands_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesCommands_Commands_Call) Return(_a0 []console.Command) *ProvidesCommands_Commands_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesCommands_Commands_Call) RunAndReturn(run func() []console.Command) *ProvidesCommands_Commands_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesCommands creates a new instance of ProvidesCommands. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesCommands(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesCommands { + mock := &ProvidesCommands{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesConfig.go b/mocks/modular/ProvidesConfig.go new file mode 100644 index 000000000..45892fb31 --- /dev/null +++ b/mocks/modular/ProvidesConfig.go @@ -0,0 +1,79 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import mock "github.com/stretchr/testify/mock" + +// ProvidesConfig is an autogenerated mock type for the ProvidesConfig type +type ProvidesConfig struct { + mock.Mock +} + +type ProvidesConfig_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesConfig) EXPECT() *ProvidesConfig_Expecter { + return &ProvidesConfig_Expecter{mock: &_m.Mock} +} + +// Config provides a mock function with no fields +func (_m *ProvidesConfig) Config() map[string]interface{} { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Config") + } + + var r0 map[string]interface{} + if rf, ok := ret.Get(0).(func() map[string]interface{}); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]interface{}) + } + } + + return r0 +} + +// ProvidesConfig_Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Config' +type ProvidesConfig_Config_Call struct { + *mock.Call +} + +// Config is a helper method to define mock.On call +func (_e *ProvidesConfig_Expecter) Config() *ProvidesConfig_Config_Call { + return &ProvidesConfig_Config_Call{Call: _e.mock.On("Config")} +} + +func (_c *ProvidesConfig_Config_Call) Run(run func()) *ProvidesConfig_Config_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesConfig_Config_Call) Return(_a0 map[string]interface{}) *ProvidesConfig_Config_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesConfig_Config_Call) RunAndReturn(run func() map[string]interface{}) *ProvidesConfig_Config_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesConfig creates a new instance of ProvidesConfig. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesConfig(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesConfig { + mock := &ProvidesConfig{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesEvents.go b/mocks/modular/ProvidesEvents.go new file mode 100644 index 000000000..82e7e09c6 --- /dev/null +++ b/mocks/modular/ProvidesEvents.go @@ -0,0 +1,82 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import ( + event "github.com/goravel/framework/contracts/event" + mock "github.com/stretchr/testify/mock" +) + +// ProvidesEvents is an autogenerated mock type for the ProvidesEvents type +type ProvidesEvents struct { + mock.Mock +} + +type ProvidesEvents_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesEvents) EXPECT() *ProvidesEvents_Expecter { + return &ProvidesEvents_Expecter{mock: &_m.Mock} +} + +// Events provides a mock function with no fields +func (_m *ProvidesEvents) Events() map[event.Event][]event.Listener { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Events") + } + + var r0 map[event.Event][]event.Listener + if rf, ok := ret.Get(0).(func() map[event.Event][]event.Listener); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[event.Event][]event.Listener) + } + } + + return r0 +} + +// ProvidesEvents_Events_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Events' +type ProvidesEvents_Events_Call struct { + *mock.Call +} + +// Events is a helper method to define mock.On call +func (_e *ProvidesEvents_Expecter) Events() *ProvidesEvents_Events_Call { + return &ProvidesEvents_Events_Call{Call: _e.mock.On("Events")} +} + +func (_c *ProvidesEvents_Events_Call) Run(run func()) *ProvidesEvents_Events_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesEvents_Events_Call) Return(_a0 map[event.Event][]event.Listener) *ProvidesEvents_Events_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesEvents_Events_Call) RunAndReturn(run func() map[event.Event][]event.Listener) *ProvidesEvents_Events_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesEvents creates a new instance of ProvidesEvents. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesEvents(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesEvents { + mock := &ProvidesEvents{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesGlobalMiddleware.go b/mocks/modular/ProvidesGlobalMiddleware.go new file mode 100644 index 000000000..c103e1179 --- /dev/null +++ b/mocks/modular/ProvidesGlobalMiddleware.go @@ -0,0 +1,82 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import ( + http "github.com/goravel/framework/contracts/http" + mock "github.com/stretchr/testify/mock" +) + +// ProvidesGlobalMiddleware is an autogenerated mock type for the ProvidesGlobalMiddleware type +type ProvidesGlobalMiddleware struct { + mock.Mock +} + +type ProvidesGlobalMiddleware_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesGlobalMiddleware) EXPECT() *ProvidesGlobalMiddleware_Expecter { + return &ProvidesGlobalMiddleware_Expecter{mock: &_m.Mock} +} + +// GlobalMiddleware provides a mock function with no fields +func (_m *ProvidesGlobalMiddleware) GlobalMiddleware() []http.Middleware { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GlobalMiddleware") + } + + var r0 []http.Middleware + if rf, ok := ret.Get(0).(func() []http.Middleware); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]http.Middleware) + } + } + + return r0 +} + +// ProvidesGlobalMiddleware_GlobalMiddleware_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalMiddleware' +type ProvidesGlobalMiddleware_GlobalMiddleware_Call struct { + *mock.Call +} + +// GlobalMiddleware is a helper method to define mock.On call +func (_e *ProvidesGlobalMiddleware_Expecter) GlobalMiddleware() *ProvidesGlobalMiddleware_GlobalMiddleware_Call { + return &ProvidesGlobalMiddleware_GlobalMiddleware_Call{Call: _e.mock.On("GlobalMiddleware")} +} + +func (_c *ProvidesGlobalMiddleware_GlobalMiddleware_Call) Run(run func()) *ProvidesGlobalMiddleware_GlobalMiddleware_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesGlobalMiddleware_GlobalMiddleware_Call) Return(_a0 []http.Middleware) *ProvidesGlobalMiddleware_GlobalMiddleware_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesGlobalMiddleware_GlobalMiddleware_Call) RunAndReturn(run func() []http.Middleware) *ProvidesGlobalMiddleware_GlobalMiddleware_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesGlobalMiddleware creates a new instance of ProvidesGlobalMiddleware. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesGlobalMiddleware(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesGlobalMiddleware { + mock := &ProvidesGlobalMiddleware{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesJobs.go b/mocks/modular/ProvidesJobs.go new file mode 100644 index 000000000..85215af12 --- /dev/null +++ b/mocks/modular/ProvidesJobs.go @@ -0,0 +1,83 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import ( + mock "github.com/stretchr/testify/mock" + + queue "github.com/goravel/framework/contracts/queue" +) + +// ProvidesJobs is an autogenerated mock type for the ProvidesJobs type +type ProvidesJobs struct { + mock.Mock +} + +type ProvidesJobs_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesJobs) EXPECT() *ProvidesJobs_Expecter { + return &ProvidesJobs_Expecter{mock: &_m.Mock} +} + +// Jobs provides a mock function with no fields +func (_m *ProvidesJobs) Jobs() []queue.Job { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Jobs") + } + + var r0 []queue.Job + if rf, ok := ret.Get(0).(func() []queue.Job); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]queue.Job) + } + } + + return r0 +} + +// ProvidesJobs_Jobs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Jobs' +type ProvidesJobs_Jobs_Call struct { + *mock.Call +} + +// Jobs is a helper method to define mock.On call +func (_e *ProvidesJobs_Expecter) Jobs() *ProvidesJobs_Jobs_Call { + return &ProvidesJobs_Jobs_Call{Call: _e.mock.On("Jobs")} +} + +func (_c *ProvidesJobs_Jobs_Call) Run(run func()) *ProvidesJobs_Jobs_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesJobs_Jobs_Call) Return(_a0 []queue.Job) *ProvidesJobs_Jobs_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesJobs_Jobs_Call) RunAndReturn(run func() []queue.Job) *ProvidesJobs_Jobs_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesJobs creates a new instance of ProvidesJobs. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesJobs(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesJobs { + mock := &ProvidesJobs{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesMigrations.go b/mocks/modular/ProvidesMigrations.go new file mode 100644 index 000000000..e2ffa69be --- /dev/null +++ b/mocks/modular/ProvidesMigrations.go @@ -0,0 +1,83 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import ( + mock "github.com/stretchr/testify/mock" + + schema "github.com/goravel/framework/contracts/database/schema" +) + +// ProvidesMigrations is an autogenerated mock type for the ProvidesMigrations type +type ProvidesMigrations struct { + mock.Mock +} + +type ProvidesMigrations_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesMigrations) EXPECT() *ProvidesMigrations_Expecter { + return &ProvidesMigrations_Expecter{mock: &_m.Mock} +} + +// Migrations provides a mock function with no fields +func (_m *ProvidesMigrations) Migrations() []schema.Migration { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Migrations") + } + + var r0 []schema.Migration + if rf, ok := ret.Get(0).(func() []schema.Migration); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]schema.Migration) + } + } + + return r0 +} + +// ProvidesMigrations_Migrations_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Migrations' +type ProvidesMigrations_Migrations_Call struct { + *mock.Call +} + +// Migrations is a helper method to define mock.On call +func (_e *ProvidesMigrations_Expecter) Migrations() *ProvidesMigrations_Migrations_Call { + return &ProvidesMigrations_Migrations_Call{Call: _e.mock.On("Migrations")} +} + +func (_c *ProvidesMigrations_Migrations_Call) Run(run func()) *ProvidesMigrations_Migrations_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesMigrations_Migrations_Call) Return(_a0 []schema.Migration) *ProvidesMigrations_Migrations_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesMigrations_Migrations_Call) RunAndReturn(run func() []schema.Migration) *ProvidesMigrations_Migrations_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesMigrations creates a new instance of ProvidesMigrations. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesMigrations(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesMigrations { + mock := &ProvidesMigrations{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesRoutes.go b/mocks/modular/ProvidesRoutes.go new file mode 100644 index 000000000..5d3aca3e0 --- /dev/null +++ b/mocks/modular/ProvidesRoutes.go @@ -0,0 +1,69 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import ( + mock "github.com/stretchr/testify/mock" + + route "github.com/goravel/framework/contracts/route" +) + +// ProvidesRoutes is an autogenerated mock type for the ProvidesRoutes type +type ProvidesRoutes struct { + mock.Mock +} + +type ProvidesRoutes_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesRoutes) EXPECT() *ProvidesRoutes_Expecter { + return &ProvidesRoutes_Expecter{mock: &_m.Mock} +} + +// Routes provides a mock function with given fields: router +func (_m *ProvidesRoutes) Routes(router route.Router) { + _m.Called(router) +} + +// ProvidesRoutes_Routes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Routes' +type ProvidesRoutes_Routes_Call struct { + *mock.Call +} + +// Routes is a helper method to define mock.On call +// - router route.Router +func (_e *ProvidesRoutes_Expecter) Routes(router interface{}) *ProvidesRoutes_Routes_Call { + return &ProvidesRoutes_Routes_Call{Call: _e.mock.On("Routes", router)} +} + +func (_c *ProvidesRoutes_Routes_Call) Run(run func(router route.Router)) *ProvidesRoutes_Routes_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(route.Router)) + }) + return _c +} + +func (_c *ProvidesRoutes_Routes_Call) Return() *ProvidesRoutes_Routes_Call { + _c.Call.Return() + return _c +} + +func (_c *ProvidesRoutes_Routes_Call) RunAndReturn(run func(route.Router)) *ProvidesRoutes_Routes_Call { + _c.Run(run) + return _c +} + +// NewProvidesRoutes creates a new instance of ProvidesRoutes. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesRoutes(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesRoutes { + mock := &ProvidesRoutes{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesSchedule.go b/mocks/modular/ProvidesSchedule.go new file mode 100644 index 000000000..30c14ec8b --- /dev/null +++ b/mocks/modular/ProvidesSchedule.go @@ -0,0 +1,83 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import ( + mock "github.com/stretchr/testify/mock" + + schedule "github.com/goravel/framework/contracts/schedule" +) + +// ProvidesSchedule is an autogenerated mock type for the ProvidesSchedule type +type ProvidesSchedule struct { + mock.Mock +} + +type ProvidesSchedule_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesSchedule) EXPECT() *ProvidesSchedule_Expecter { + return &ProvidesSchedule_Expecter{mock: &_m.Mock} +} + +// Schedule provides a mock function with no fields +func (_m *ProvidesSchedule) Schedule() []schedule.Event { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Schedule") + } + + var r0 []schedule.Event + if rf, ok := ret.Get(0).(func() []schedule.Event); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]schedule.Event) + } + } + + return r0 +} + +// ProvidesSchedule_Schedule_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Schedule' +type ProvidesSchedule_Schedule_Call struct { + *mock.Call +} + +// Schedule is a helper method to define mock.On call +func (_e *ProvidesSchedule_Expecter) Schedule() *ProvidesSchedule_Schedule_Call { + return &ProvidesSchedule_Schedule_Call{Call: _e.mock.On("Schedule")} +} + +func (_c *ProvidesSchedule_Schedule_Call) Run(run func()) *ProvidesSchedule_Schedule_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesSchedule_Schedule_Call) Return(_a0 []schedule.Event) *ProvidesSchedule_Schedule_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesSchedule_Schedule_Call) RunAndReturn(run func() []schedule.Event) *ProvidesSchedule_Schedule_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesSchedule creates a new instance of ProvidesSchedule. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesSchedule(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesSchedule { + mock := &ProvidesSchedule{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesSeeders.go b/mocks/modular/ProvidesSeeders.go new file mode 100644 index 000000000..c94984267 --- /dev/null +++ b/mocks/modular/ProvidesSeeders.go @@ -0,0 +1,83 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import ( + mock "github.com/stretchr/testify/mock" + + seeder "github.com/goravel/framework/contracts/database/seeder" +) + +// ProvidesSeeders is an autogenerated mock type for the ProvidesSeeders type +type ProvidesSeeders struct { + mock.Mock +} + +type ProvidesSeeders_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesSeeders) EXPECT() *ProvidesSeeders_Expecter { + return &ProvidesSeeders_Expecter{mock: &_m.Mock} +} + +// Seeders provides a mock function with no fields +func (_m *ProvidesSeeders) Seeders() []seeder.Seeder { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Seeders") + } + + var r0 []seeder.Seeder + if rf, ok := ret.Get(0).(func() []seeder.Seeder); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]seeder.Seeder) + } + } + + return r0 +} + +// ProvidesSeeders_Seeders_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Seeders' +type ProvidesSeeders_Seeders_Call struct { + *mock.Call +} + +// Seeders is a helper method to define mock.On call +func (_e *ProvidesSeeders_Expecter) Seeders() *ProvidesSeeders_Seeders_Call { + return &ProvidesSeeders_Seeders_Call{Call: _e.mock.On("Seeders")} +} + +func (_c *ProvidesSeeders_Seeders_Call) Run(run func()) *ProvidesSeeders_Seeders_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesSeeders_Seeders_Call) Return(_a0 []seeder.Seeder) *ProvidesSeeders_Seeders_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesSeeders_Seeders_Call) RunAndReturn(run func() []seeder.Seeder) *ProvidesSeeders_Seeders_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesSeeders creates a new instance of ProvidesSeeders. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesSeeders(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesSeeders { + mock := &ProvidesSeeders{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesStatic.go b/mocks/modular/ProvidesStatic.go new file mode 100644 index 000000000..c53f98806 --- /dev/null +++ b/mocks/modular/ProvidesStatic.go @@ -0,0 +1,82 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import ( + modular "github.com/goravel/framework/contracts/modular" + mock "github.com/stretchr/testify/mock" +) + +// ProvidesStatic is an autogenerated mock type for the ProvidesStatic type +type ProvidesStatic struct { + mock.Mock +} + +type ProvidesStatic_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesStatic) EXPECT() *ProvidesStatic_Expecter { + return &ProvidesStatic_Expecter{mock: &_m.Mock} +} + +// Static provides a mock function with no fields +func (_m *ProvidesStatic) Static() map[string]modular.Path { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Static") + } + + var r0 map[string]modular.Path + if rf, ok := ret.Get(0).(func() map[string]modular.Path); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]modular.Path) + } + } + + return r0 +} + +// ProvidesStatic_Static_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Static' +type ProvidesStatic_Static_Call struct { + *mock.Call +} + +// Static is a helper method to define mock.On call +func (_e *ProvidesStatic_Expecter) Static() *ProvidesStatic_Static_Call { + return &ProvidesStatic_Static_Call{Call: _e.mock.On("Static")} +} + +func (_c *ProvidesStatic_Static_Call) Run(run func()) *ProvidesStatic_Static_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesStatic_Static_Call) Return(_a0 map[string]modular.Path) *ProvidesStatic_Static_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesStatic_Static_Call) RunAndReturn(run func() map[string]modular.Path) *ProvidesStatic_Static_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesStatic creates a new instance of ProvidesStatic. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesStatic(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesStatic { + mock := &ProvidesStatic{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesTranslations.go b/mocks/modular/ProvidesTranslations.go new file mode 100644 index 000000000..761366f3a --- /dev/null +++ b/mocks/modular/ProvidesTranslations.go @@ -0,0 +1,83 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import ( + fs "io/fs" + + mock "github.com/stretchr/testify/mock" +) + +// ProvidesTranslations is an autogenerated mock type for the ProvidesTranslations type +type ProvidesTranslations struct { + mock.Mock +} + +type ProvidesTranslations_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesTranslations) EXPECT() *ProvidesTranslations_Expecter { + return &ProvidesTranslations_Expecter{mock: &_m.Mock} +} + +// Translations provides a mock function with no fields +func (_m *ProvidesTranslations) Translations() fs.FS { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Translations") + } + + var r0 fs.FS + if rf, ok := ret.Get(0).(func() fs.FS); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(fs.FS) + } + } + + return r0 +} + +// ProvidesTranslations_Translations_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Translations' +type ProvidesTranslations_Translations_Call struct { + *mock.Call +} + +// Translations is a helper method to define mock.On call +func (_e *ProvidesTranslations_Expecter) Translations() *ProvidesTranslations_Translations_Call { + return &ProvidesTranslations_Translations_Call{Call: _e.mock.On("Translations")} +} + +func (_c *ProvidesTranslations_Translations_Call) Run(run func()) *ProvidesTranslations_Translations_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesTranslations_Translations_Call) Return(_a0 fs.FS) *ProvidesTranslations_Translations_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesTranslations_Translations_Call) RunAndReturn(run func() fs.FS) *ProvidesTranslations_Translations_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesTranslations creates a new instance of ProvidesTranslations. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesTranslations(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesTranslations { + mock := &ProvidesTranslations{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesViews.go b/mocks/modular/ProvidesViews.go new file mode 100644 index 000000000..3ea191259 --- /dev/null +++ b/mocks/modular/ProvidesViews.go @@ -0,0 +1,83 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import ( + fs "io/fs" + + mock "github.com/stretchr/testify/mock" +) + +// ProvidesViews is an autogenerated mock type for the ProvidesViews type +type ProvidesViews struct { + mock.Mock +} + +type ProvidesViews_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesViews) EXPECT() *ProvidesViews_Expecter { + return &ProvidesViews_Expecter{mock: &_m.Mock} +} + +// Views provides a mock function with no fields +func (_m *ProvidesViews) Views() fs.FS { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Views") + } + + var r0 fs.FS + if rf, ok := ret.Get(0).(func() fs.FS); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(fs.FS) + } + } + + return r0 +} + +// ProvidesViews_Views_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Views' +type ProvidesViews_Views_Call struct { + *mock.Call +} + +// Views is a helper method to define mock.On call +func (_e *ProvidesViews_Expecter) Views() *ProvidesViews_Views_Call { + return &ProvidesViews_Views_Call{Call: _e.mock.On("Views")} +} + +func (_c *ProvidesViews_Views_Call) Run(run func()) *ProvidesViews_Views_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesViews_Views_Call) Return(_a0 fs.FS) *ProvidesViews_Views_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesViews_Views_Call) RunAndReturn(run func() fs.FS) *ProvidesViews_Views_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesViews creates a new instance of ProvidesViews. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesViews(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesViews { + mock := &ProvidesViews{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesViteAlias.go b/mocks/modular/ProvidesViteAlias.go new file mode 100644 index 000000000..6dc6b53cd --- /dev/null +++ b/mocks/modular/ProvidesViteAlias.go @@ -0,0 +1,79 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import mock "github.com/stretchr/testify/mock" + +// ProvidesViteAlias is an autogenerated mock type for the ProvidesViteAlias type +type ProvidesViteAlias struct { + mock.Mock +} + +type ProvidesViteAlias_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesViteAlias) EXPECT() *ProvidesViteAlias_Expecter { + return &ProvidesViteAlias_Expecter{mock: &_m.Mock} +} + +// ViteAliases provides a mock function with no fields +func (_m *ProvidesViteAlias) ViteAliases() map[string]string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for ViteAliases") + } + + var r0 map[string]string + if rf, ok := ret.Get(0).(func() map[string]string); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]string) + } + } + + return r0 +} + +// ProvidesViteAlias_ViteAliases_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ViteAliases' +type ProvidesViteAlias_ViteAliases_Call struct { + *mock.Call +} + +// ViteAliases is a helper method to define mock.On call +func (_e *ProvidesViteAlias_Expecter) ViteAliases() *ProvidesViteAlias_ViteAliases_Call { + return &ProvidesViteAlias_ViteAliases_Call{Call: _e.mock.On("ViteAliases")} +} + +func (_c *ProvidesViteAlias_ViteAliases_Call) Run(run func()) *ProvidesViteAlias_ViteAliases_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesViteAlias_ViteAliases_Call) Return(_a0 map[string]string) *ProvidesViteAlias_ViteAliases_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesViteAlias_ViteAliases_Call) RunAndReturn(run func() map[string]string) *ProvidesViteAlias_ViteAliases_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesViteAlias creates a new instance of ProvidesViteAlias. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesViteAlias(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesViteAlias { + mock := &ProvidesViteAlias{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/ProvidesViteEntries.go b/mocks/modular/ProvidesViteEntries.go new file mode 100644 index 000000000..7fe81c189 --- /dev/null +++ b/mocks/modular/ProvidesViteEntries.go @@ -0,0 +1,79 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import mock "github.com/stretchr/testify/mock" + +// ProvidesViteEntries is an autogenerated mock type for the ProvidesViteEntries type +type ProvidesViteEntries struct { + mock.Mock +} + +type ProvidesViteEntries_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesViteEntries) EXPECT() *ProvidesViteEntries_Expecter { + return &ProvidesViteEntries_Expecter{mock: &_m.Mock} +} + +// ViteEntries provides a mock function with no fields +func (_m *ProvidesViteEntries) ViteEntries() []string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for ViteEntries") + } + + var r0 []string + if rf, ok := ret.Get(0).(func() []string); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + return r0 +} + +// ProvidesViteEntries_ViteEntries_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ViteEntries' +type ProvidesViteEntries_ViteEntries_Call struct { + *mock.Call +} + +// ViteEntries is a helper method to define mock.On call +func (_e *ProvidesViteEntries_Expecter) ViteEntries() *ProvidesViteEntries_ViteEntries_Call { + return &ProvidesViteEntries_ViteEntries_Call{Call: _e.mock.On("ViteEntries")} +} + +func (_c *ProvidesViteEntries_ViteEntries_Call) Run(run func()) *ProvidesViteEntries_ViteEntries_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesViteEntries_ViteEntries_Call) Return(_a0 []string) *ProvidesViteEntries_ViteEntries_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesViteEntries_ViteEntries_Call) RunAndReturn(run func() []string) *ProvidesViteEntries_ViteEntries_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesViteEntries creates a new instance of ProvidesViteEntries. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesViteEntries(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesViteEntries { + mock := &ProvidesViteEntries{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/modular/Registry.go b/mocks/modular/Registry.go new file mode 100644 index 000000000..2769bf3ad --- /dev/null +++ b/mocks/modular/Registry.go @@ -0,0 +1,349 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import ( + modular "github.com/goravel/framework/contracts/modular" + mock "github.com/stretchr/testify/mock" +) + +// Registry is an autogenerated mock type for the Registry type +type Registry struct { + mock.Mock +} + +type Registry_Expecter struct { + mock *mock.Mock +} + +func (_m *Registry) EXPECT() *Registry_Expecter { + return &Registry_Expecter{mock: &_m.Mock} +} + +// All provides a mock function with no fields +func (_m *Registry) All() []modular.Module { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for All") + } + + var r0 []modular.Module + if rf, ok := ret.Get(0).(func() []modular.Module); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]modular.Module) + } + } + + return r0 +} + +// Registry_All_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'All' +type Registry_All_Call struct { + *mock.Call +} + +// All is a helper method to define mock.On call +func (_e *Registry_Expecter) All() *Registry_All_Call { + return &Registry_All_Call{Call: _e.mock.On("All")} +} + +func (_c *Registry_All_Call) Run(run func()) *Registry_All_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *Registry_All_Call) Return(_a0 []modular.Module) *Registry_All_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Registry_All_Call) RunAndReturn(run func() []modular.Module) *Registry_All_Call { + _c.Call.Return(run) + return _c +} + +// Capabilities provides a mock function with given fields: module +func (_m *Registry) Capabilities(module modular.Module) map[string]bool { + ret := _m.Called(module) + + if len(ret) == 0 { + panic("no return value specified for Capabilities") + } + + var r0 map[string]bool + if rf, ok := ret.Get(0).(func(modular.Module) map[string]bool); ok { + r0 = rf(module) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]bool) + } + } + + return r0 +} + +// Registry_Capabilities_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Capabilities' +type Registry_Capabilities_Call struct { + *mock.Call +} + +// Capabilities is a helper method to define mock.On call +// - module modular.Module +func (_e *Registry_Expecter) Capabilities(module interface{}) *Registry_Capabilities_Call { + return &Registry_Capabilities_Call{Call: _e.mock.On("Capabilities", module)} +} + +func (_c *Registry_Capabilities_Call) Run(run func(module modular.Module)) *Registry_Capabilities_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(modular.Module)) + }) + return _c +} + +func (_c *Registry_Capabilities_Call) Return(_a0 map[string]bool) *Registry_Capabilities_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Registry_Capabilities_Call) RunAndReturn(run func(modular.Module) map[string]bool) *Registry_Capabilities_Call { + _c.Call.Return(run) + return _c +} + +// Find provides a mock function with given fields: name +func (_m *Registry) Find(name string) (modular.Module, bool) { + ret := _m.Called(name) + + if len(ret) == 0 { + panic("no return value specified for Find") + } + + var r0 modular.Module + var r1 bool + if rf, ok := ret.Get(0).(func(string) (modular.Module, bool)); ok { + return rf(name) + } + if rf, ok := ret.Get(0).(func(string) modular.Module); ok { + r0 = rf(name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(modular.Module) + } + } + + if rf, ok := ret.Get(1).(func(string) bool); ok { + r1 = rf(name) + } else { + r1 = ret.Get(1).(bool) + } + + return r0, r1 +} + +// Registry_Find_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Find' +type Registry_Find_Call struct { + *mock.Call +} + +// Find is a helper method to define mock.On call +// - name string +func (_e *Registry_Expecter) Find(name interface{}) *Registry_Find_Call { + return &Registry_Find_Call{Call: _e.mock.On("Find", name)} +} + +func (_c *Registry_Find_Call) Run(run func(name string)) *Registry_Find_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *Registry_Find_Call) Return(_a0 modular.Module, _a1 bool) *Registry_Find_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Registry_Find_Call) RunAndReturn(run func(string) (modular.Module, bool)) *Registry_Find_Call { + _c.Call.Return(run) + return _c +} + +// Has provides a mock function with given fields: name +func (_m *Registry) Has(name string) bool { + ret := _m.Called(name) + + if len(ret) == 0 { + panic("no return value specified for Has") + } + + var r0 bool + if rf, ok := ret.Get(0).(func(string) bool); ok { + r0 = rf(name) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// Registry_Has_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Has' +type Registry_Has_Call struct { + *mock.Call +} + +// Has is a helper method to define mock.On call +// - name string +func (_e *Registry_Expecter) Has(name interface{}) *Registry_Has_Call { + return &Registry_Has_Call{Call: _e.mock.On("Has", name)} +} + +func (_c *Registry_Has_Call) Run(run func(name string)) *Registry_Has_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *Registry_Has_Call) Return(_a0 bool) *Registry_Has_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Registry_Has_Call) RunAndReturn(run func(string) bool) *Registry_Has_Call { + _c.Call.Return(run) + return _c +} + +// Path provides a mock function with given fields: name +func (_m *Registry) Path(name string) (string, bool) { + ret := _m.Called(name) + + if len(ret) == 0 { + panic("no return value specified for Path") + } + + var r0 string + var r1 bool + if rf, ok := ret.Get(0).(func(string) (string, bool)); ok { + return rf(name) + } + if rf, ok := ret.Get(0).(func(string) string); ok { + r0 = rf(name) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(string) bool); ok { + r1 = rf(name) + } else { + r1 = ret.Get(1).(bool) + } + + return r0, r1 +} + +// Registry_Path_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Path' +type Registry_Path_Call struct { + *mock.Call +} + +// Path is a helper method to define mock.On call +// - name string +func (_e *Registry_Expecter) Path(name interface{}) *Registry_Path_Call { + return &Registry_Path_Call{Call: _e.mock.On("Path", name)} +} + +func (_c *Registry_Path_Call) Run(run func(name string)) *Registry_Path_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *Registry_Path_Call) Return(_a0 string, _a1 bool) *Registry_Path_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Registry_Path_Call) RunAndReturn(run func(string) (string, bool)) *Registry_Path_Call { + _c.Call.Return(run) + return _c +} + +// Register provides a mock function with given fields: modules +func (_m *Registry) Register(modules ...modular.Module) error { + _va := make([]interface{}, len(modules)) + for _i := range modules { + _va[_i] = modules[_i] + } + var _ca []interface{} + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for Register") + } + + var r0 error + if rf, ok := ret.Get(0).(func(...modular.Module) error); ok { + r0 = rf(modules...) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Registry_Register_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Register' +type Registry_Register_Call struct { + *mock.Call +} + +// Register is a helper method to define mock.On call +// - modules ...modular.Module +func (_e *Registry_Expecter) Register(modules ...interface{}) *Registry_Register_Call { + return &Registry_Register_Call{Call: _e.mock.On("Register", + append([]interface{}{}, modules...)...)} +} + +func (_c *Registry_Register_Call) Run(run func(modules ...modular.Module)) *Registry_Register_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]modular.Module, len(args)-0) + for i, a := range args[0:] { + if a != nil { + variadicArgs[i] = a.(modular.Module) + } + } + run(variadicArgs...) + }) + return _c +} + +func (_c *Registry_Register_Call) Return(_a0 error) *Registry_Register_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Registry_Register_Call) RunAndReturn(run func(...modular.Module) error) *Registry_Register_Call { + _c.Call.Return(run) + return _c +} + +// NewRegistry creates a new instance of Registry. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewRegistry(t interface { + mock.TestingT + Cleanup(func()) +}) *Registry { + mock := &Registry{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} From 41c56b2bc68e33b01ee81aed6b8879ad331f3aea Mon Sep 17 00:00:00 2001 From: Saad Date: Wed, 11 Nov 2026 10:30:38 +0600 Subject: [PATCH 06/29] feat(modular): add filesystems for module views, translations and static files - NewPrefixFS mounts a module's views under its name, so "index.tmpl" is the view "blog/index.tmpl"; it passes fstest.TestFS and works with View.LoadViewsFromFS and view.Compile. - NewLangFS serves every module's translations as groups "/" through one app.lang_fs, asking the application's own lang FS first so an app file can override a module's. - StaticFS resolves a module's static Path (embedded, or on disk under the module's directory, inside the module only) and refuses to open directories, so gin and fiber both answer 404 instead of listing them. CleanPrefix normalises the URL prefix and refuses "/". --- errors/list.go | 8 +- modular/lang_fs.go | 72 +++++++++++++ modular/lang_fs_test.go | 97 +++++++++++++++++ modular/prefix_fs.go | 218 ++++++++++++++++++++++++++++++++++++++ modular/prefix_fs_test.go | 74 +++++++++++++ modular/static.go | 82 ++++++++++++++ modular/static_test.go | 100 +++++++++++++++++ 7 files changed, 648 insertions(+), 3 deletions(-) create mode 100644 modular/lang_fs.go create mode 100644 modular/lang_fs_test.go create mode 100644 modular/prefix_fs.go create mode 100644 modular/prefix_fs_test.go create mode 100644 modular/static.go create mode 100644 modular/static_test.go diff --git a/errors/list.go b/errors/list.go index 4f9260f98..4df6d4de4 100644 --- a/errors/list.go +++ b/errors/list.go @@ -217,9 +217,11 @@ var ( MigrationResetFailed = New("migration reset failed: %v") MigrationRollbackFailed = New("migration rollback failed: %v") - ModularModuleDuplicate = New("module %q is registered more than once").SetModule(ModuleModular) - ModularModulePathInvalid = New("module %s: directory %q must be a relative path inside the project").SetModule(ModuleModular) - ModularModulePathUnknown = New("module %s: cannot derive its directory from package %q, implement Path() to set it").SetModule(ModuleModular) + ModularModuleDuplicate = New("module %q is registered more than once").SetModule(ModuleModular) + ModularModulePathInvalid = New("module %s: directory %q must be a relative path inside the project").SetModule(ModuleModular) + ModularModulePathUnknown = New("module %s: cannot derive its directory from package %q, implement Path() to set it").SetModule(ModuleModular) + ModularStaticDirInvalid = New("module %s: static directory %q must be a relative path inside the module").SetModule(ModuleModular) + ModularStaticDirUnreadable = New("module %s: static directory %q is not a readable directory: %v").SetModule(ModuleModular) NotificationChannelNotFound = New("notification channel not found: %s").SetModule(ModuleNotification) NotificationChannelNotQueueable = New("notification channel %q does not support queued dispatch (does not implement ResolvableChannel)").SetModule(ModuleNotification) diff --git a/modular/lang_fs.go b/modular/lang_fs.go new file mode 100644 index 000000000..7c4a8e8ef --- /dev/null +++ b/modular/lang_fs.go @@ -0,0 +1,72 @@ +package modular + +import ( + "errors" + "io/fs" + "strings" +) + +var _ fs.ReadFileFS = (*langFS)(nil) + +// langFS maps "en/blog/messages.json" to the blog module's "en/messages.json". +// The app's own lang FS (fallback) is asked first, so it can override modules. +type langFS struct { + modules map[string]fs.FS + fallback fs.FS +} + +// NewLangFS combines module translations, keyed by name, with an optional fallback. +// A langFS fallback is unwrapped: its modules are replaced and its own fallback kept. +func NewLangFS(modules map[string]fs.FS, fallback fs.FS) fs.FS { + if prev, ok := fallback.(*langFS); ok { + fallback = prev.fallback + } + + return &langFS{modules: modules, fallback: fallback} +} + +func (r *langFS) Open(name string) (fs.File, error) { + return open(r, "open", name, func(fsys fs.FS, name string) (fs.File, error) { + return fsys.Open(name) + }) +} + +func (r *langFS) ReadFile(name string) ([]byte, error) { + return open(r, "readfile", name, fs.ReadFile) +} + +// open asks the fallback, then the owning module. +func open[T any](r *langFS, op, name string, read func(fs.FS, string) (T, error)) (T, error) { + var zero T + if !fs.ValidPath(name) { + return zero, &fs.PathError{Op: op, Path: name, Err: fs.ErrInvalid} + } + + module, inner, owned := r.owner(name) + if r.fallback != nil { + result, err := read(r.fallback, name) + if err == nil || !owned || !errors.Is(err, fs.ErrNotExist) { + return result, err + } + } + if !owned { + return zero, &fs.PathError{Op: op, Path: name, Err: fs.ErrNotExist} + } + + return read(module, inner) +} + +// owner splits "//". +func (r *langFS) owner(name string) (fs.FS, string, bool) { + parts := strings.SplitN(name, "/", 3) + if len(parts) < 3 { + return nil, "", false + } + + module, ok := r.modules[parts[1]] + if !ok || module == nil { + return nil, "", false + } + + return module, parts[0] + "/" + parts[2], true +} diff --git a/modular/lang_fs_test.go b/modular/lang_fs_test.go new file mode 100644 index 000000000..853b32dde --- /dev/null +++ b/modular/lang_fs_test.go @@ -0,0 +1,97 @@ +package modular + +import ( + "io/fs" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/goravel/framework/errors" + "github.com/goravel/framework/foundation/json" + "github.com/goravel/framework/translation" +) + +func TestLangFS(t *testing.T) { + modules := map[string]fs.FS{ + "blog": fstest.MapFS{ + "en/messages.json": {Data: []byte(`{"title": "Blog"}`)}, + "en/admin/posts.json": {Data: []byte(`{"title": "Posts"}`)}, + "en/overridden.json": {Data: []byte(`{"title": "Module"}`)}, + "fr/messages.json": {Data: []byte(`{"title": "Blogue"}`)}, + }, + } + fallback := fstest.MapFS{ + "en.json": {Data: []byte(`{"hello": "Hello"}`)}, + "en/app.json": {Data: []byte(`{"name": "App"}`)}, + "en/blog/overridden.json": {Data: []byte(`{"title": "App"}`)}, + } + + loader := translation.NewFSLoader(NewLangFS(modules, fallback), json.New()) + + tests := []struct { + locale, group string + expected map[string]any + }{ + {"en", "blog/messages", map[string]any{"title": "Blog"}}, + {"fr", "blog/messages", map[string]any{"title": "Blogue"}}, + {"en", "blog/admin/posts", map[string]any{"title": "Posts"}}, + {"en", "blog/overridden", map[string]any{"title": "App"}}, + {"en", "app", map[string]any{"name": "App"}}, + {"en", "*", map[string]any{"hello": "Hello"}}, + } + for _, tt := range tests { + values, err := loader.Load(tt.locale, tt.group) + require.NoError(t, err, tt.group) + assert.Equal(t, tt.expected, values, tt.group) + } + + for _, group := range []string{"blog/missing", "shop/messages", "missing"} { + _, err := loader.Load("en", group) + assert.ErrorIs(t, err, errors.LangFileNotExist, group) + } + _, err := loader.Load("de", "blog/messages") + assert.ErrorIs(t, err, errors.LangFileNotExist) +} + +func TestLangFS_WithoutFallback(t *testing.T) { + fsys := NewLangFS(map[string]fs.FS{"blog": fstest.MapFS{"en/messages.json": {Data: []byte(`{}`)}}}, nil) + + _, err := fs.ReadFile(fsys, "en/blog/messages.json") + assert.NoError(t, err) + + for _, name := range []string{"en.json", "en/app.json", "en/shop/messages.json"} { + _, err := fs.ReadFile(fsys, name) + assert.ErrorIs(t, err, fs.ErrNotExist, name) + + _, err = fsys.Open(name) + assert.ErrorIs(t, err, fs.ErrNotExist, name) + } + + _, err = fsys.Open("../en.json") + assert.ErrorIs(t, err, fs.ErrInvalid) +} + +func TestLangFS_UnwrapsPrevious(t *testing.T) { + fallback := fstest.MapFS{"en/app.json": {Data: []byte(`{"name": "App"}`)}} + first := NewLangFS(map[string]fs.FS{ + "blog": fstest.MapFS{"en/messages.json": {Data: []byte(`{"title": "Old"}`)}}, + "shop": fstest.MapFS{"en/messages.json": {Data: []byte(`{}`)}}, + }, fallback) + second := NewLangFS(map[string]fs.FS{ + "blog": fstest.MapFS{"en/messages.json": {Data: []byte(`{"title": "New"}`)}}, + }, first) + + assert.Equal(t, fs.FS(fallback), second.(*langFS).fallback) + + data, err := fs.ReadFile(second, "en/blog/messages.json") + require.NoError(t, err) + assert.JSONEq(t, `{"title": "New"}`, string(data)) + + _, err = fs.ReadFile(second, "en/app.json") + assert.NoError(t, err) + + _, err = fs.ReadFile(second, "en/shop/messages.json") + assert.ErrorIs(t, err, fs.ErrNotExist) +} diff --git a/modular/prefix_fs.go b/modular/prefix_fs.go new file mode 100644 index 000000000..3e4f16db7 --- /dev/null +++ b/modular/prefix_fs.go @@ -0,0 +1,218 @@ +package modular + +import ( + "io" + "io/fs" + "strings" + "time" +) + +var ( + _ fs.ReadDirFS = (*prefixFS)(nil) + _ fs.ReadFileFS = (*prefixFS)(nil) + _ fs.StatFS = (*prefixFS)(nil) +) + +// prefixFS mounts inner at name: "blog/index.tmpl" is inner's "index.tmpl". +type prefixFS struct { + name string + inner fs.FS +} + +// NewPrefixFS mounts inner at name, a single path element. +func NewPrefixFS(name string, inner fs.FS) fs.FS { + return &prefixFS{name: name, inner: inner} +} + +func (r *prefixFS) Open(name string) (fs.File, error) { + inner, kind, err := r.resolve("open", name) + if err != nil { + return nil, err + } + + switch kind { + case pathRoot: + info, err := r.mountInfo() + if err != nil { + return nil, &fs.PathError{Op: "open", Path: name, Err: err} + } + + return &rootDir{info: rootInfo{modTime: info.ModTime()}, entries: []fs.DirEntry{fs.FileInfoToDirEntry(info)}}, nil + case pathMount: + file, err := r.inner.Open(".") + if err != nil { + return nil, &fs.PathError{Op: "open", Path: name, Err: err} + } + + return &renamedFile{File: file, name: r.name}, nil + default: + return r.inner.Open(inner) + } +} + +func (r *prefixFS) ReadDir(name string) ([]fs.DirEntry, error) { + inner, kind, err := r.resolve("readdir", name) + if err != nil { + return nil, err + } + + if kind == pathRoot { + info, err := r.mountInfo() + if err != nil { + return nil, &fs.PathError{Op: "readdir", Path: name, Err: err} + } + + return []fs.DirEntry{fs.FileInfoToDirEntry(info)}, nil + } + + return fs.ReadDir(r.inner, inner) +} + +func (r *prefixFS) ReadFile(name string) ([]byte, error) { + inner, kind, err := r.resolve("readfile", name) + if err != nil { + return nil, err + } + if kind != pathInner { + return nil, &fs.PathError{Op: "readfile", Path: name, Err: fs.ErrInvalid} + } + + return fs.ReadFile(r.inner, inner) +} + +func (r *prefixFS) Stat(name string) (fs.FileInfo, error) { + inner, kind, err := r.resolve("stat", name) + if err != nil { + return nil, err + } + + switch kind { + case pathRoot: + info, err := r.mountInfo() + if err != nil { + return nil, &fs.PathError{Op: "stat", Path: name, Err: err} + } + + return rootInfo{modTime: info.ModTime()}, nil + case pathMount: + return r.mountInfo() + default: + return fs.Stat(r.inner, inner) + } +} + +type pathKind int + +const ( + pathRoot pathKind = iota // "." + pathMount // name + pathInner // name/x +) + +// resolve classifies name and returns inner's path for pathInner. +func (r *prefixFS) resolve(op, name string) (string, pathKind, error) { + if !fs.ValidPath(name) { + return "", 0, &fs.PathError{Op: op, Path: name, Err: fs.ErrInvalid} + } + + switch { + case name == ".": + return "", pathRoot, nil + case name == r.name: + return ".", pathMount, nil + case strings.HasPrefix(name, r.name+"/"): + return strings.TrimPrefix(name, r.name+"/"), pathInner, nil + default: + return "", 0, &fs.PathError{Op: op, Path: name, Err: fs.ErrNotExist} + } +} + +// mountInfo is inner's root, renamed to the mount point. +func (r *prefixFS) mountInfo() (fs.FileInfo, error) { + info, err := fs.Stat(r.inner, ".") + if err != nil { + return nil, err + } + + return renamedInfo{FileInfo: info, name: r.name}, nil +} + +// rootInfo describes the synthetic root. +type rootInfo struct{ modTime time.Time } + +func (i rootInfo) Name() string { return "." } +func (i rootInfo) Size() int64 { return 0 } +func (i rootInfo) Mode() fs.FileMode { return fs.ModeDir | 0o555 } +func (i rootInfo) ModTime() time.Time { return i.modTime } +func (i rootInfo) IsDir() bool { return true } +func (i rootInfo) Sys() any { return nil } + +// rootDir lists only the mount point. +type rootDir struct { + info rootInfo + entries []fs.DirEntry + offset int +} + +func (d *rootDir) Stat() (fs.FileInfo, error) { return d.info, nil } + +func (d *rootDir) Read([]byte) (int, error) { + return 0, &fs.PathError{Op: "read", Path: ".", Err: fs.ErrInvalid} +} + +func (d *rootDir) Close() error { return nil } + +func (d *rootDir) ReadDir(n int) ([]fs.DirEntry, error) { + return readDirEntries(d.entries, &d.offset, n) +} + +// renamedInfo overrides a file's name. +type renamedInfo struct { + fs.FileInfo + name string +} + +func (i renamedInfo) Name() string { return i.name } + +// renamedFile is inner's root, named after the mount point. +type renamedFile struct { + fs.File + name string +} + +func (f *renamedFile) Stat() (fs.FileInfo, error) { + info, err := f.File.Stat() + if err != nil { + return nil, err + } + + return renamedInfo{FileInfo: info, name: f.name}, nil +} + +func (f *renamedFile) ReadDir(n int) ([]fs.DirEntry, error) { + dir, ok := f.File.(fs.ReadDirFile) + if !ok { + return nil, &fs.PathError{Op: "readdir", Path: f.name, Err: fs.ErrInvalid} + } + + return dir.ReadDir(n) +} + +// readDirEntries implements ReadDir over a fixed list. +func readDirEntries(entries []fs.DirEntry, offset *int, n int) ([]fs.DirEntry, error) { + rest := entries[*offset:] + if n <= 0 { + *offset = len(entries) + + return rest, nil + } + if len(rest) == 0 { + return nil, io.EOF + } + if n > len(rest) { + n = len(rest) + } + *offset += n + + return rest[:n], nil +} diff --git a/modular/prefix_fs_test.go b/modular/prefix_fs_test.go new file mode 100644 index 000000000..208800077 --- /dev/null +++ b/modular/prefix_fs_test.go @@ -0,0 +1,74 @@ +package modular + +import ( + "bytes" + "io/fs" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/goravel/framework/support" + "github.com/goravel/framework/view" +) + +func blogViews() fstest.MapFS { + return fstest.MapFS{ + "index.tmpl": {Data: []byte(`{{ template "blog/layouts/base.tmpl" . }}`)}, + "layouts/base.tmpl": {Data: []byte(`

{{ .title }}

`)}, + } +} + +func TestPrefixFS_TestFS(t *testing.T) { + assert.NoError(t, fstest.TestFS(NewPrefixFS("blog", blogViews()), "blog/index.tmpl", "blog/layouts/base.tmpl")) +} + +func TestPrefixFS_Paths(t *testing.T) { + fsys := NewPrefixFS("blog", blogViews()) + + entries, err := fs.ReadDir(fsys, ".") + require.NoError(t, err) + require.Len(t, entries, 1) + assert.Equal(t, "blog", entries[0].Name()) + assert.True(t, entries[0].IsDir()) + + info, err := fs.Stat(fsys, "blog") + require.NoError(t, err) + assert.Equal(t, "blog", info.Name()) + assert.True(t, info.IsDir()) + + content, err := fs.ReadFile(fsys, "blog/layouts/base.tmpl") + require.NoError(t, err) + assert.Equal(t, `

{{ .title }}

`, string(content)) + + for _, name := range []string{"index.tmpl", "shop/index.tmpl", "blogs/index.tmpl", "blog/missing.tmpl"} { + _, err := fsys.Open(name) + assert.ErrorIs(t, err, fs.ErrNotExist, name) + } + + _, err = fsys.Open("../blog") + assert.ErrorIs(t, err, fs.ErrInvalid) +} + +// Views load and compile from the mounted FS, named under the module. +func TestPrefixFS_Views(t *testing.T) { + relativePath := support.RelativePath + t.Cleanup(func() { support.RelativePath = relativePath }) + support.RelativePath = t.TempDir() + + fsys := NewPrefixFS("blog", blogViews()) + + registry := view.NewView() + assert.NotPanics(t, func() { registry.LoadViewsFromFS(fsys, ".") }) + assert.True(t, registry.Exists("blog/index.tmpl")) + assert.False(t, registry.Exists("blog")) + assert.False(t, registry.Exists("index.tmpl")) + + tmpl, err := view.Compile(view.CompileOptions{FS: registry.RegisteredViewFS()}) + require.NoError(t, err) + + var out bytes.Buffer + require.NoError(t, tmpl.ExecuteTemplate(&out, "blog/index.tmpl", map[string]any{"title": "Blog"})) + assert.Equal(t, "

Blog

", out.String()) +} diff --git a/modular/static.go b/modular/static.go new file mode 100644 index 000000000..50a35d69c --- /dev/null +++ b/modular/static.go @@ -0,0 +1,82 @@ +package modular + +import ( + "io/fs" + "os" + stdpath "path" + "strings" + + "github.com/goravel/framework/contracts/modular" + "github.com/goravel/framework/errors" + "github.com/goravel/framework/support/path" +) + +// StaticFS returns p.Dir inside p.FS, or on disk under root (the module directory) +// when p.FS is nil. Directories are not listable, so drivers answer 404. +func StaticFS(name, root string, p modular.Path) (fs.FS, error) { + dir, ok := path.CleanRelative(p.Dir) + if !ok { + return nil, errors.ModularStaticDirInvalid.Args(name, p.Dir) + } + + var fsys fs.FS + if p.FS != nil { + sub, err := fs.Sub(p.FS, dir) + if err != nil { + return nil, errors.ModularStaticDirUnreadable.Args(name, p.Dir, err) + } + fsys = sub + } else { + base, ok := path.CleanRelative(root) + if !ok { + return nil, errors.ModularStaticDirInvalid.Args(name, root) + } + fsys = os.DirFS(path.Base(base, dir)) + } + + info, err := fs.Stat(fsys, ".") + if err != nil { + return nil, errors.ModularStaticDirUnreadable.Args(name, p.Dir, err) + } + if !info.IsDir() { + return nil, errors.ModularStaticDirUnreadable.Args(name, p.Dir, fs.ErrInvalid) + } + + return noListingFS{fsys}, nil +} + +// CleanPrefix normalises a URL prefix to "/a/b"; "/" is refused. +func CleanPrefix(prefix string) (string, bool) { + cleaned := stdpath.Clean("/" + strings.TrimSpace(prefix)) + if cleaned == "/" { + return "", false + } + + return cleaned, true +} + +// noListingFS refuses to open directories. +type noListingFS struct { + fs.FS +} + +func (r noListingFS) Open(name string) (fs.File, error) { + file, err := r.FS.Open(name) + if err != nil { + return nil, err + } + + info, err := file.Stat() + if err != nil { + _ = file.Close() + + return nil, err + } + if info.IsDir() { + _ = file.Close() + + return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist} + } + + return file, nil +} diff --git a/modular/static_test.go b/modular/static_test.go new file mode 100644 index 000000000..029727bab --- /dev/null +++ b/modular/static_test.go @@ -0,0 +1,100 @@ +package modular + +import ( + "io/fs" + "os" + "path/filepath" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + contractsmodular "github.com/goravel/framework/contracts/modular" + "github.com/goravel/framework/errors" + "github.com/goravel/framework/support" +) + +func TestStaticFS_Embedded(t *testing.T) { + embedded := fstest.MapFS{ + "public/robots.txt": {Data: []byte("User-agent: *")}, + "public/img/logo.png": {Data: []byte("png")}, + "resources/secret.txt": {Data: []byte("secret")}, + } + + fsys, err := StaticFS("blog", "modules/blog", contractsmodular.Path{FS: embedded, Dir: "public"}) + require.NoError(t, err) + + content, err := fs.ReadFile(fsys, "robots.txt") + require.NoError(t, err) + assert.Equal(t, "User-agent: *", string(content)) + + _, err = fs.ReadFile(fsys, "img/logo.png") + assert.NoError(t, err) + + for _, name := range []string{".", "img"} { + _, err := fsys.Open(name) + assert.ErrorIs(t, err, fs.ErrNotExist, "directory %s must not be listable", name) + } + + _, err = fsys.Open("../resources/secret.txt") + assert.Error(t, err) +} + +func TestStaticFS_Disk(t *testing.T) { + relativePath := support.RelativePath + t.Cleanup(func() { support.RelativePath = relativePath }) + support.RelativePath = t.TempDir() + + dir := filepath.Join(support.RelativePath, "modules", "blog", "public") + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "robots.txt"), []byte("disk"), 0o644)) + + fsys, err := StaticFS("blog", "modules/blog", contractsmodular.Path{Dir: "public"}) + require.NoError(t, err) + + content, err := fs.ReadFile(fsys, "robots.txt") + require.NoError(t, err) + assert.Equal(t, "disk", string(content)) + + _, err = StaticFS("blog", "modules/blog", contractsmodular.Path{Dir: "missing"}) + assert.ErrorIs(t, err, errors.ModularStaticDirUnreadable) +} + +func TestStaticFS_Invalid(t *testing.T) { + embedded := fstest.MapFS{"public/robots.txt": {Data: []byte("x")}} + + for _, dir := range []string{"..", "../shop", "public/../../x", "/etc"} { + _, err := StaticFS("blog", "modules/blog", contractsmodular.Path{FS: embedded, Dir: dir}) + assert.ErrorIs(t, err, errors.ModularStaticDirInvalid, dir) + } + + _, err := StaticFS("blog", "../blog", contractsmodular.Path{Dir: "public"}) + assert.ErrorIs(t, err, errors.ModularStaticDirInvalid, "the module directory must stay inside the project") + + _, err = StaticFS("blog", "modules/blog", contractsmodular.Path{FS: embedded, Dir: "public/robots.txt"}) + assert.ErrorIs(t, err, errors.ModularStaticDirUnreadable, "a file is not a directory") + + _, err = StaticFS("blog", "modules/blog", contractsmodular.Path{FS: embedded, Dir: "missing"}) + assert.ErrorIs(t, err, errors.ModularStaticDirUnreadable) +} + +func TestCleanPrefix(t *testing.T) { + tests := []struct { + prefix, expected string + ok bool + }{ + {"blog", "/blog", true}, + {"/blog/", "/blog", true}, + {"/blog/static", "/blog/static", true}, + {"/blog/../shop", "/shop", true}, + {"/", "", false}, + {"", "", false}, + {"..", "", false}, + } + for _, tt := range tests { + cleaned, ok := CleanPrefix(tt.prefix) + assert.Equal(t, tt.ok, ok, tt.prefix) + assert.Equal(t, tt.expected, cleaned, tt.prefix) + } +} From 3f42756f43db2f93119c8fc54d1ffe7f8130233e Mon Sep 17 00:00:00 2001 From: Saad Date: Wed, 11 Nov 2026 10:37:11 +0600 Subject: [PATCH 07/29] feat(foundation): add WithModules and the module registry binding foundation.Setup().WithModules(Modules) registers the application's modules. Build() binds a fresh registry (binding "goravel.modular") right after WithConfig, so modular.path (where third-party modules are placed, "modules" by default) is known, available as facades.Modular() and app.MakeModular(); an application booted without foundation.Setup() has none. A duplicate module name or a module whose directory cannot be resolved is reported and the modules before it are kept. --- contracts/binding/binding.go | 1 + contracts/facades/facades.go | 2 + contracts/foundation/application.go | 3 + contracts/foundation/application_builder.go | 4 + facades/facades.go | 6 + foundation/application.go | 29 +++++ foundation/application_builder.go | 8 ++ foundation/application_builder_test.go | 14 +++ foundation/application_modules_test.go | 117 ++++++++++++++++++++ foundation/container.go | 12 ++ mocks/foundation/Application.go | 49 ++++++++ mocks/foundation/ApplicationBuilder.go | 50 +++++++++ 12 files changed, 295 insertions(+) create mode 100644 foundation/application_modules_test.go diff --git a/contracts/binding/binding.go b/contracts/binding/binding.go index 9064b970f..420d71d12 100644 --- a/contracts/binding/binding.go +++ b/contracts/binding/binding.go @@ -17,6 +17,7 @@ const ( Lang = "goravel.lang" Log = "goravel.log" Mail = "goravel.mail" + Modular = "goravel.modular" Notification = "goravel.notification" Orm = "goravel.orm" Process = "goravel.process" diff --git a/contracts/facades/facades.go b/contracts/facades/facades.go index b12255e31..401593099 100644 --- a/contracts/facades/facades.go +++ b/contracts/facades/facades.go @@ -19,6 +19,7 @@ const ( Lang = "Lang" Log = "Log" Mail = "Mail" + Modular = "Modular" Notification = "Notification" Process = "Process" Orm = "Orm" @@ -54,6 +55,7 @@ var FacadeToBinding = map[string]string{ Lang: binding.Lang, Log: binding.Log, Mail: binding.Mail, + Modular: binding.Modular, Notification: binding.Notification, Orm: binding.Orm, Process: binding.Process, diff --git a/contracts/foundation/application.go b/contracts/foundation/application.go index 11a90909e..a667e8124 100644 --- a/contracts/foundation/application.go +++ b/contracts/foundation/application.go @@ -23,6 +23,7 @@ import ( "github.com/goravel/framework/contracts/http/client" "github.com/goravel/framework/contracts/log" "github.com/goravel/framework/contracts/mail" + "github.com/goravel/framework/contracts/modular" "github.com/goravel/framework/contracts/notification" "github.com/goravel/framework/contracts/process" "github.com/goravel/framework/contracts/queue" @@ -180,6 +181,8 @@ type Application interface { MakeLog() log.Log // MakeMail resolves the mail instance. MakeMail() mail.Mail + // MakeModular resolves the module registry; nil without foundation.Setup(). + MakeModular() modular.Registry // MakeNotification resolves the notification instance. MakeNotification() notification.Manager // MakeOrm resolves the orm instance. diff --git a/contracts/foundation/application_builder.go b/contracts/foundation/application_builder.go index 4cd2b2802..eeaaa60c1 100644 --- a/contracts/foundation/application_builder.go +++ b/contracts/foundation/application_builder.go @@ -12,6 +12,7 @@ import ( "github.com/goravel/framework/contracts/database/seeder" "github.com/goravel/framework/contracts/event" "github.com/goravel/framework/contracts/foundation/configuration" + "github.com/goravel/framework/contracts/modular" "github.com/goravel/framework/contracts/queue" "github.com/goravel/framework/contracts/schedule" "github.com/goravel/framework/contracts/validation" @@ -72,6 +73,9 @@ type ApplicationBuilder interface { WithMiddleware(func(handler configuration.Middleware)) ApplicationBuilder // WithMigrations registers the database migrations. WithMigrations(func() []schema.Migration) ApplicationBuilder + // WithModules registers the modules (bootstrap/modules.go). Their contributions + // are merged into each Build() step after the application's own. + WithModules(func() []modular.Module) ApplicationBuilder // WithPaths sets custom paths for the application. WithPaths(func(paths configuration.Paths)) ApplicationBuilder // WithProviders registers and boots custom service providers. diff --git a/facades/facades.go b/facades/facades.go index 7ef5e6ecb..41c649b33 100644 --- a/facades/facades.go +++ b/facades/facades.go @@ -24,6 +24,7 @@ import ( "github.com/goravel/framework/contracts/http/client" "github.com/goravel/framework/contracts/log" "github.com/goravel/framework/contracts/mail" + "github.com/goravel/framework/contracts/modular" "github.com/goravel/framework/contracts/notification" "github.com/goravel/framework/contracts/process" "github.com/goravel/framework/contracts/queue" @@ -112,6 +113,11 @@ func Mail() mail.Mail { return App().MakeMail() } +// Modular returns the module registry; nil without foundation.Setup(). +func Modular() modular.Registry { + return App().MakeModular() +} + func Notification() notification.Manager { return App().MakeNotification() } diff --git a/foundation/application.go b/foundation/application.go index 332b2c3e2..04bca4eac 100644 --- a/foundation/application.go +++ b/foundation/application.go @@ -25,12 +25,14 @@ import ( "github.com/goravel/framework/contracts/binding" contractsconsole "github.com/goravel/framework/contracts/console" "github.com/goravel/framework/contracts/foundation" + contractsmodular "github.com/goravel/framework/contracts/modular" "github.com/goravel/framework/contracts/validation" contractsview "github.com/goravel/framework/contracts/view" "github.com/goravel/framework/errors" "github.com/goravel/framework/foundation/configuration" "github.com/goravel/framework/foundation/console" "github.com/goravel/framework/foundation/json" + "github.com/goravel/framework/modular" "github.com/goravel/framework/process" "github.com/goravel/framework/support" "github.com/goravel/framework/support/carbon" @@ -79,6 +81,7 @@ type Application struct { bootedRunners []string commandsFilter []string commandHooks map[string][]contractsconsole.CommandHook + modules []contractsmodular.Module runnerWg sync.WaitGroup runnersToRun []*RunnerWithInfo } @@ -116,6 +119,7 @@ func (r *Application) Build() foundation.Application { r.setTimezone() r.configurePaths() r.configureCustomConfig() + r.configureModules() if r.builder.commandsFilter != nil { r.commandsFilter = r.builder.commandsFilter() } @@ -622,6 +626,31 @@ func (r *Application) configureMigrations() { } } +// configureModules binds a fresh registry and keeps the sorted modules. It runs +// after WithConfig (modular.path) and before providers. Errors are reported. +func (r *Application) configureModules() { + directory := modular.DefaultDirectory + var modules []contractsmodular.Module + if r.builder.modules != nil { + modules = r.builder.modules() + } + if len(modules) > 0 { + if config := r.MakeConfig(); config != nil { + directory = config.GetString("modular.path", modular.DefaultDirectory) + } + } + + registry := modular.NewRegistry(directory) + if err := registry.Register(modules...); err != nil { + color.Errorln(err) + } + + // Make caches instances; drop the previous registry on a rebuild. + r.Fresh(binding.Modular) + r.Instance(binding.Modular, registry) + r.modules = registry.All() +} + func (r *Application) configurePaths() { if r.builder.paths != nil { paths := configuration.NewPaths() diff --git a/foundation/application_builder.go b/foundation/application_builder.go index b2038afef..16ec515d8 100644 --- a/foundation/application_builder.go +++ b/foundation/application_builder.go @@ -13,6 +13,7 @@ import ( "github.com/goravel/framework/contracts/event" "github.com/goravel/framework/contracts/foundation" contractsconfiguration "github.com/goravel/framework/contracts/foundation/configuration" + "github.com/goravel/framework/contracts/modular" "github.com/goravel/framework/contracts/queue" "github.com/goravel/framework/contracts/schedule" "github.com/goravel/framework/contracts/validation" @@ -41,6 +42,7 @@ type ApplicationBuilder struct { jobs func() []queue.Job middleware func(middleware contractsconfiguration.Middleware) migrations func() []schema.Migration + modules func() []modular.Module paths func(paths contractsconfiguration.Paths) routes func() rules func() []validation.Rule @@ -179,6 +181,12 @@ func (r *ApplicationBuilder) WithMigrations(fn func() []schema.Migration) founda return r } +func (r *ApplicationBuilder) WithModules(fn func() []modular.Module) foundation.ApplicationBuilder { + r.modules = fn + + return r +} + func (r *ApplicationBuilder) WithPaths(fn func(paths contractsconfiguration.Paths)) foundation.ApplicationBuilder { r.paths = fn diff --git a/foundation/application_builder_test.go b/foundation/application_builder_test.go index 3d17a1ae4..e814d7960 100644 --- a/foundation/application_builder_test.go +++ b/foundation/application_builder_test.go @@ -18,6 +18,7 @@ import ( contractsevent "github.com/goravel/framework/contracts/event" contractsfoundation "github.com/goravel/framework/contracts/foundation" contractsconfiguration "github.com/goravel/framework/contracts/foundation/configuration" + "github.com/goravel/framework/contracts/modular" "github.com/goravel/framework/contracts/queue" "github.com/goravel/framework/contracts/schedule" "github.com/goravel/framework/contracts/validation" @@ -25,6 +26,7 @@ import ( mocksschema "github.com/goravel/framework/mocks/database/schema" mocksseeder "github.com/goravel/framework/mocks/database/seeder" mocksfoundation "github.com/goravel/framework/mocks/foundation" + mocksmodular "github.com/goravel/framework/mocks/modular" mocksqueue "github.com/goravel/framework/mocks/queue" mocksschedule "github.com/goravel/framework/mocks/schedule" mocksvalidation "github.com/goravel/framework/mocks/validation" @@ -127,6 +129,18 @@ func (s *ApplicationBuilderTestSuite) TestWithPaths() { s.NotNil(s.builder.paths) } +func (s *ApplicationBuilderTestSuite) TestWithModules() { + mockModule := mocksmodular.NewModule(s.T()) + + builder := s.builder.WithModules(func() []modular.Module { + return []modular.Module{mockModule} + }) + + s.NotNil(builder) + s.Require().NotNil(s.builder.modules) + s.Equal([]modular.Module{mockModule}, s.builder.modules()) +} + func (s *ApplicationBuilderTestSuite) TestWithMigrations() { mockMigration := mocksschema.NewMigration(s.T()) diff --git a/foundation/application_modules_test.go b/foundation/application_modules_test.go new file mode 100644 index 000000000..95d3e0414 --- /dev/null +++ b/foundation/application_modules_test.go @@ -0,0 +1,117 @@ +package foundation + +import ( + "github.com/goravel/framework/contracts/binding" + contractsmodular "github.com/goravel/framework/contracts/modular" + mocksconfig "github.com/goravel/framework/mocks/config" +) + +// testModule has only a name; tests embed it to add capabilities. +type testModule struct{ name string } + +func (m *testModule) Name() string { return m.name } + +type orderedTestModule struct { + testModule + order int +} + +func (m *orderedTestModule) Order() int { return m.order } + +func moduleNames(modules []contractsmodular.Module) []string { + var names []string + for _, module := range modules { + names = append(names, module.Name()) + } + + return names +} + +func (s *ApplicationTestSuite) TestConfigureModules() { + s.Run("without WithModules an empty registry is bound", func() { + s.SetupTest() + s.app.builder = NewApplicationBuilder(s.app) + + s.app.configureModules() + + registry := s.app.MakeModular() + s.Require().NotNil(registry) + s.Empty(registry.All()) + s.Empty(s.app.modules) + }) + + s.Run("modules are registered and kept in order", func() { + s.SetupTest() + builder := NewApplicationBuilder(s.app) + builder.modules = func() []contractsmodular.Module { + return []contractsmodular.Module{ + &orderedTestModule{testModule{"last"}, -1}, + &testModule{"blog"}, + &orderedTestModule{testModule{"shop"}, 5}, + } + } + s.app.builder = builder + + s.app.configureModules() + + s.Equal([]string{"blog", "shop", "last"}, moduleNames(s.app.modules)) + s.True(s.app.MakeModular().Has("blog")) + instance, err := s.app.Make(binding.Modular) + s.NoError(err) + s.Same(s.app.MakeModular(), instance) + }) + + s.Run("module directories are resolved, third-party ones under modular.path", func() { + s.SetupTest() + mockConfig := mocksconfig.NewConfig(s.T()) + mockConfig.EXPECT().GetString("modular.path", "modules").Return("app/modules").Once() + s.app.Instance(binding.Config, mockConfig) + builder := NewApplicationBuilder(s.app) + builder.modules = func() []contractsmodular.Module { + return []contractsmodular.Module{&testModule{"blog"}} + } + s.app.builder = builder + + s.app.configureModules() + + // Under go test the main module is the framework. + dir, ok := s.app.MakeModular().Path("blog") + s.True(ok) + s.Equal("foundation", dir) + }) + + s.Run("a duplicate name is reported and the modules before it are kept", func() { + s.SetupTest() + builder := NewApplicationBuilder(s.app) + builder.modules = func() []contractsmodular.Module { + return []contractsmodular.Module{&testModule{"blog"}, &testModule{"blog"}, &testModule{"shop"}} + } + s.app.builder = builder + + s.app.configureModules() + + s.Equal([]string{"blog"}, moduleNames(s.app.modules)) + }) + + s.Run("a rebuild binds a fresh registry", func() { + s.SetupTest() + builder := NewApplicationBuilder(s.app) + builder.modules = func() []contractsmodular.Module { + return []contractsmodular.Module{&testModule{"blog"}} + } + s.app.builder = builder + + s.app.configureModules() + first := s.app.MakeModular() + s.app.configureModules() + + s.NotSame(first, s.app.MakeModular()) + s.Len(s.app.MakeModular().All(), 1) + }) + + s.Run("without Build there is no registry", func() { + s.SetupTest() + + s.Nil(s.app.MakeModular()) + }) +} diff --git a/foundation/container.go b/foundation/container.go index 60a07ce84..f2eaed5ed 100644 --- a/foundation/container.go +++ b/foundation/container.go @@ -28,6 +28,7 @@ import ( contractshttpclient "github.com/goravel/framework/contracts/http/client" contractslog "github.com/goravel/framework/contracts/log" contractsmail "github.com/goravel/framework/contracts/mail" + contractsmodular "github.com/goravel/framework/contracts/modular" contractsnotification "github.com/goravel/framework/contracts/notification" contractsprocess "github.com/goravel/framework/contracts/process" contractsqueue "github.com/goravel/framework/contracts/queue" @@ -283,6 +284,17 @@ func (r *Container) MakeMail() contractsmail.Mail { return instance.(contractsmail.Mail) } + +func (r *Container) MakeModular() contractsmodular.Registry { + instance, err := r.Make(facades.FacadeToBinding[facades.Modular]) + if err != nil { + logMakeErrorIfNeeded(err) + return nil + } + + return instance.(contractsmodular.Registry) +} + func (r *Container) MakeNotification() contractsnotification.Manager { instance, err := r.Make(facades.FacadeToBinding[facades.Notification]) if err != nil { diff --git a/mocks/foundation/Application.go b/mocks/foundation/Application.go index 407065206..fd4684370 100644 --- a/mocks/foundation/Application.go +++ b/mocks/foundation/Application.go @@ -42,6 +42,8 @@ import ( mock "github.com/stretchr/testify/mock" + modular "github.com/goravel/framework/contracts/modular" + notification "github.com/goravel/framework/contracts/notification" orm "github.com/goravel/framework/contracts/database/orm" @@ -1989,6 +1991,53 @@ func (_c *Application_MakeMail_Call) RunAndReturn(run func() mail.Mail) *Applica return _c } +// MakeModular provides a mock function with no fields +func (_m *Application) MakeModular() modular.Registry { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for MakeModular") + } + + var r0 modular.Registry + if rf, ok := ret.Get(0).(func() modular.Registry); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(modular.Registry) + } + } + + return r0 +} + +// Application_MakeModular_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MakeModular' +type Application_MakeModular_Call struct { + *mock.Call +} + +// MakeModular is a helper method to define mock.On call +func (_e *Application_Expecter) MakeModular() *Application_MakeModular_Call { + return &Application_MakeModular_Call{Call: _e.mock.On("MakeModular")} +} + +func (_c *Application_MakeModular_Call) Run(run func()) *Application_MakeModular_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *Application_MakeModular_Call) Return(_a0 modular.Registry) *Application_MakeModular_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Application_MakeModular_Call) RunAndReturn(run func() modular.Registry) *Application_MakeModular_Call { + _c.Call.Return(run) + return _c +} + // MakeNotification provides a mock function with no fields func (_m *Application) MakeNotification() notification.Manager { ret := _m.Called() diff --git a/mocks/foundation/ApplicationBuilder.go b/mocks/foundation/ApplicationBuilder.go index 91ee8b164..bc6603445 100644 --- a/mocks/foundation/ApplicationBuilder.go +++ b/mocks/foundation/ApplicationBuilder.go @@ -16,6 +16,8 @@ import ( mock "github.com/stretchr/testify/mock" + modular "github.com/goravel/framework/contracts/modular" + queue "github.com/goravel/framework/contracts/queue" schedule "github.com/goravel/framework/contracts/schedule" @@ -859,6 +861,54 @@ func (_c *ApplicationBuilder_WithMigrations_Call) RunAndReturn(run func(func() [ return _c } +// WithModules provides a mock function with given fields: _a0 +func (_m *ApplicationBuilder) WithModules(_a0 func() []modular.Module) foundation.ApplicationBuilder { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for WithModules") + } + + var r0 foundation.ApplicationBuilder + if rf, ok := ret.Get(0).(func(func() []modular.Module) foundation.ApplicationBuilder); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(foundation.ApplicationBuilder) + } + } + + return r0 +} + +// ApplicationBuilder_WithModules_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WithModules' +type ApplicationBuilder_WithModules_Call struct { + *mock.Call +} + +// WithModules is a helper method to define mock.On call +// - _a0 func() []modular.Module +func (_e *ApplicationBuilder_Expecter) WithModules(_a0 interface{}) *ApplicationBuilder_WithModules_Call { + return &ApplicationBuilder_WithModules_Call{Call: _e.mock.On("WithModules", _a0)} +} + +func (_c *ApplicationBuilder_WithModules_Call) Run(run func(_a0 func() []modular.Module)) *ApplicationBuilder_WithModules_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(func() []modular.Module)) + }) + return _c +} + +func (_c *ApplicationBuilder_WithModules_Call) Return(_a0 foundation.ApplicationBuilder) *ApplicationBuilder_WithModules_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ApplicationBuilder_WithModules_Call) RunAndReturn(run func(func() []modular.Module) foundation.ApplicationBuilder) *ApplicationBuilder_WithModules_Call { + _c.Call.Return(run) + return _c +} + // WithPaths provides a mock function with given fields: _a0 func (_m *ApplicationBuilder) WithPaths(_a0 func(configuration.Paths)) foundation.ApplicationBuilder { ret := _m.Called(_a0) From 63e6d15184b1cb6b88e4e3d4843e666f021b8d35 Mon Sep 17 00:00:00 2001 From: Saad Date: Wed, 11 Nov 2026 10:46:11 +0600 Subject: [PATCH 08/29] feat(foundation): merge module contributions into Build Each Build() step now takes the modules' contributions after the application's own: - config: "modules." in a step of its own after the registry is bound, so after WithConfig; translations through app.lang_fs, with an existing app.lang_fs kept as fallback - providers: added with WithProviders, sorted by relationships as usual - global middleware: driver defaults, then modules, then the WithMiddleware callback, which sees the full list - views: registered under "/" after providers boot and before the driver compiles the view set - commands, migrations, seeders, jobs, schedule: appended in module order; commands go through the commands filter and hooks like app commands - events: merged per event, so listeners of a shared event are kept - routes: static paths on the root router (no group middleware, disk paths relative to the module's resolved directory), then web and API routes inside the groups configured by modular.routes.web and modular.routes.api ("" and "api" by default) Steps a module does not use are untouched: an application without modules builds exactly as before. --- errors/list.go | 3 + foundation/application.go | 298 +++++++++++++++++---- foundation/application_modules_test.go | 354 +++++++++++++++++++++++++ 3 files changed, 608 insertions(+), 47 deletions(-) diff --git a/errors/list.go b/errors/list.go index 4df6d4de4..247c043f6 100644 --- a/errors/list.go +++ b/errors/list.go @@ -222,6 +222,9 @@ var ( ModularModulePathUnknown = New("module %s: cannot derive its directory from package %q, implement Path() to set it").SetModule(ModuleModular) ModularStaticDirInvalid = New("module %s: static directory %q must be a relative path inside the module").SetModule(ModuleModular) ModularStaticDirUnreadable = New("module %s: static directory %q is not a readable directory: %v").SetModule(ModuleModular) + ModularStaticDuplicate = New("static path %s of module %s is already served by module %s").SetModule(ModuleModular) + ModularStaticPrefixInvalid = New("module %s: static prefix %q is invalid, use a path below /").SetModule(ModuleModular) + ModularViewsFSRequired = New("module %s returned a nil views filesystem").SetModule(ModuleModular) NotificationChannelNotFound = New("notification channel not found: %s").SetModule(ModuleNotification) NotificationChannelNotQueueable = New("notification channel %q does not support queued dispatch (does not implement ResolvableChannel)").SetModule(ModuleNotification) diff --git a/foundation/application.go b/foundation/application.go index 04bca4eac..1588a5c33 100644 --- a/foundation/application.go +++ b/foundation/application.go @@ -4,7 +4,9 @@ import ( "context" "flag" "fmt" + "io/fs" "maps" + "net/http" "os" "os/signal" stdpath "path" @@ -23,9 +25,17 @@ import ( "github.com/goravel/framework/config" frameworkconsole "github.com/goravel/framework/console" "github.com/goravel/framework/contracts/binding" + contractsconfig "github.com/goravel/framework/contracts/config" contractsconsole "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/database/schema" + "github.com/goravel/framework/contracts/database/seeder" + "github.com/goravel/framework/contracts/event" "github.com/goravel/framework/contracts/foundation" + contractshttp "github.com/goravel/framework/contracts/http" contractsmodular "github.com/goravel/framework/contracts/modular" + "github.com/goravel/framework/contracts/queue" + "github.com/goravel/framework/contracts/route" + "github.com/goravel/framework/contracts/schedule" "github.com/goravel/framework/contracts/validation" contractsview "github.com/goravel/framework/contracts/view" "github.com/goravel/framework/errors" @@ -120,6 +130,7 @@ func (r *Application) Build() foundation.Application { r.configurePaths() r.configureCustomConfig() r.configureModules() + r.configureModuleConfig() if r.builder.commandsFilter != nil { r.commandsFilter = r.builder.commandsFilter() } @@ -130,6 +141,7 @@ func (r *Application) Build() foundation.Application { r.providerRepository.Register(r) r.configureMiddleware() r.providerRepository.Boot(r) + r.registerModuleResources() r.configureViews() r.configureEventListeners() r.configureCommands() @@ -488,10 +500,16 @@ func (r *Application) configureCallback() { } func (r *Application) configureCommands() { + var commands []contractsconsole.Command if r.builder.commands != nil { - if commands := r.builder.commands(); len(commands) > 0 { - r.registerCommands(commands) - } + commands = r.builder.commands() + } + for _, module := range modular.Providing[contractsmodular.ProvidesCommands](r.modules) { + commands = append(commands, module.Commands()...) + } + + if len(commands) > 0 { + r.registerCommands(commands) } } @@ -501,18 +519,65 @@ func (r *Application) configureCustomConfig() { } } +// configureModuleConfig sets "modules." (replacing WithConfig's) and wraps +// app.lang_fs with module translations. +func (r *Application) configureModuleConfig() { + translations := make(map[string]fs.FS) + var configs []contractsmodular.Module + for _, module := range r.modules { + if _, ok := module.(contractsmodular.ProvidesConfig); ok { + configs = append(configs, module) + } + if p, ok := module.(contractsmodular.ProvidesTranslations); ok { + if fsys := p.Translations(); fsys != nil { + translations[module.Name()] = fsys + } + } + } + if len(configs) == 0 && len(translations) == 0 { + return + } + + configFacade := r.MakeConfig() + if configFacade == nil { + color.Errorln(errors.ConfigFacadeNotSet.SetModule(errors.ModuleModular)) + return + } + + for _, module := range configs { + configFacade.Add("modules."+module.Name(), module.(contractsmodular.ProvidesConfig).Config()) + } + + if len(translations) > 0 { + fallback, _ := configFacade.Get("app.lang_fs").(fs.FS) + configFacade.Add("app.lang_fs", modular.NewLangFS(translations, fallback)) + } +} + func (r *Application) configureEventListeners() { + var eventToListeners map[event.Event][]event.Listener if r.builder.eventToListeners != nil { - if eventToListeners := r.builder.eventToListeners(); len(eventToListeners) > 0 { - eventFacade := r.MakeEvent() - if eventFacade == nil { - color.Errorln("Event facade not found, please install it first: ./artisan package:install Event") - } else { - // WithEvents carries the deprecated event.Listener, Register is - // the only entry accepting it. - //nolint:staticcheck - eventFacade.Register(eventToListeners) - } + eventToListeners = r.builder.eventToListeners() + } + for _, module := range modular.Providing[contractsmodular.ProvidesEvents](r.modules) { + if eventToListeners == nil { + eventToListeners = make(map[event.Event][]event.Listener) + } + // Append, so shared events keep every listener. + for e, listeners := range module.Events() { + eventToListeners[e] = append(eventToListeners[e], listeners...) + } + } + + if len(eventToListeners) > 0 { + eventFacade := r.MakeEvent() + if eventFacade == nil { + color.Errorln("Event facade not found, please install it first: ./artisan package:install Event") + } else { + // WithEvents carries the deprecated event.Listener, Register is + // the only entry accepting it. + //nolint:staticcheck + eventFacade.Register(eventToListeners) } } } @@ -581,29 +646,40 @@ func (r *Application) configureGrpc() { } func (r *Application) configureJobs() { + var jobs []queue.Job if r.builder.jobs != nil { - jobs := r.builder.jobs() + jobs = r.builder.jobs() + } + for _, module := range modular.Providing[contractsmodular.ProvidesJobs](r.modules) { + jobs = append(jobs, module.Jobs()...) + } - if len(jobs) > 0 { - queueFacade := r.MakeQueue() - if queueFacade == nil { - color.Errorln("Queue facade not found, please install it first: ./artisan package:install Queue") - } else { - queueFacade.Register(jobs) - } + if len(jobs) > 0 { + queueFacade := r.MakeQueue() + if queueFacade == nil { + color.Errorln("Queue facade not found, please install it first: ./artisan package:install Queue") + } else { + queueFacade.Register(jobs) } } } +// configureMiddleware: driver defaults, then modules, then WithMiddleware (final say). func (r *Application) configureMiddleware() { - if r.builder.middleware != nil { + modules := modular.Providing[contractsmodular.ProvidesGlobalMiddleware](r.modules) + if r.builder.middleware != nil || len(modules) > 0 { routeFacade := r.MakeRoute() if routeFacade == nil { color.Errorln("Route facade not found, please install it first: ./artisan package:install Route") } else { defaultGlobalMiddleware := routeFacade.GetGlobalMiddleware() middleware := configuration.NewMiddleware(defaultGlobalMiddleware) - r.builder.middleware(middleware) + for _, module := range modules { + middleware.Append(module.GlobalMiddleware()...) + } + if r.builder.middleware != nil { + r.builder.middleware(middleware) + } routeFacade.SetGlobalMiddleware(middleware.GetGlobalMiddleware()) if recoveryHandler := middleware.GetRecover(); recoveryHandler != nil { @@ -614,14 +690,20 @@ func (r *Application) configureMiddleware() { } func (r *Application) configureMigrations() { + var migrations []schema.Migration if r.builder.migrations != nil { - if migrations := r.builder.migrations(); len(migrations) > 0 { - schemaFacade := r.MakeSchema() - if schemaFacade == nil { - color.Errorln("Schema facade not found, please install it first: ./artisan package:install Schema") - } else { - schemaFacade.Register(migrations) - } + migrations = r.builder.migrations() + } + for _, module := range modular.Providing[contractsmodular.ProvidesMigrations](r.modules) { + migrations = append(migrations, module.Migrations()...) + } + + if len(migrations) > 0 { + schemaFacade := r.MakeSchema() + if schemaFacade == nil { + color.Errorln("Schema facade not found, please install it first: ./artisan package:install Schema") + } else { + schemaFacade.Register(migrations) } } } @@ -658,10 +740,84 @@ func (r *Application) configurePaths() { } } +// configureRoutes runs WithRouting, then module static files on the root router +// (no group middleware), then module routes in the modular.routes.* groups. func (r *Application) configureRoutes() { if r.builder.routes != nil { r.builder.routes() } + + statics := modular.Providing[contractsmodular.ProvidesStatic](r.modules) + web := modular.Providing[contractsmodular.ProvidesRoutes](r.modules) + api := modular.Providing[contractsmodular.ProvidesApiRoutes](r.modules) + if len(statics) == 0 && len(web) == 0 && len(api) == 0 { + return + } + + routeFacade := r.MakeRoute() + if routeFacade == nil { + color.Errorln("Route facade not found, please install it first: ./artisan package:install Route") + return + } + + r.registerModuleStatic(routeFacade) + + configFacade := r.MakeConfig() + webPrefix, webMiddleware := moduleRouteGroup(configFacade, "modular.routes.web", "") + apiPrefix, apiMiddleware := moduleRouteGroup(configFacade, "modular.routes.api", "api") + for _, module := range web { + routeFacade.Prefix(webPrefix).Middleware(webMiddleware...).Group(module.Routes) + } + for _, module := range api { + routeFacade.Prefix(apiPrefix).Middleware(apiMiddleware...).Group(module.ApiRoutes) + } +} + +// registerModuleStatic serves static paths in module, then prefix, order. Duplicate +// or invalid ones are reported and skipped. +func (r *Application) registerModuleStatic(routeFacade route.Route) { + registry := r.MakeModular() + served := make(map[string]string) + for _, module := range r.modules { + p, ok := module.(contractsmodular.ProvidesStatic) + if !ok { + continue + } + + paths := p.Static() + for _, prefix := range slices.Sorted(maps.Keys(paths)) { + cleaned, ok := modular.CleanPrefix(prefix) + if !ok { + color.Errorln(errors.ModularStaticPrefixInvalid.Args(module.Name(), prefix)) + continue + } + if owner, ok := served[cleaned]; ok { + color.Errorln(errors.ModularStaticDuplicate.Args(cleaned, module.Name(), owner)) + continue + } + + root, _ := registry.Path(module.Name()) + fsys, err := modular.StaticFS(module.Name(), root, paths[prefix]) + if err != nil { + color.Errorln(err) + continue + } + + served[cleaned] = module.Name() + routeFacade.StaticFS(cleaned, http.FS(fsys)) + } + } +} + +// moduleRouteGroup reads a route group's prefix and middleware. +func moduleRouteGroup(configFacade contractsconfig.Config, key, defaultPrefix string) (string, []contractshttp.Middleware) { + if configFacade == nil { + return defaultPrefix, nil + } + + middleware, _ := configFacade.Get(key + ".middleware").([]contractshttp.Middleware) + + return configFacade.GetString(key+".prefix", defaultPrefix), middleware } func (r *Application) configureRunners() { @@ -720,37 +876,85 @@ func (r *Application) isRunnerDisabled(signature string, disabledRunners []strin } func (r *Application) configureSchedule() { + var events []schedule.Event if r.builder.schedule != nil { - if events := r.builder.schedule(); len(events) > 0 { - scheduleFacade := r.MakeSchedule() - if scheduleFacade == nil { - color.Errorln("Schedule facade not found, please install it first: ./artisan package:install Schedule") - } else { - scheduleFacade.Register(events) - } + events = r.builder.schedule() + } + for _, module := range modular.Providing[contractsmodular.ProvidesSchedule](r.modules) { + events = append(events, module.Schedule()...) + } + + if len(events) > 0 { + scheduleFacade := r.MakeSchedule() + if scheduleFacade == nil { + color.Errorln("Schedule facade not found, please install it first: ./artisan package:install Schedule") + } else { + scheduleFacade.Register(events) } } } func (r *Application) configureSeeders() { + var seeders []seeder.Seeder if r.builder.seeders != nil { - if seeders := r.builder.seeders(); len(seeders) > 0 { - seederFacade := r.MakeSeeder() - if seederFacade == nil { - color.Errorln("Seeder facade not found, please install it first: ./artisan package:install Seeder") - } else { - seederFacade.Register(seeders) - } + seeders = r.builder.seeders() + } + for _, module := range modular.Providing[contractsmodular.ProvidesSeeders](r.modules) { + seeders = append(seeders, module.Seeders()...) + } + + if len(seeders) > 0 { + seederFacade := r.MakeSeeder() + if seederFacade == nil { + color.Errorln("Seeder facade not found, please install it first: ./artisan package:install Seeder") + } else { + seederFacade.Register(seeders) } } } func (r *Application) configureServiceProviders() { + var providers []foundation.ServiceProvider if r.builder.configuredServiceProviders != nil { - configuredServiceProviders := r.builder.configuredServiceProviders() - if len(configuredServiceProviders) > 0 { - r.providerRepository.Add(configuredServiceProviders) + providers = r.builder.configuredServiceProviders() + } + for _, module := range modular.Providing[foundation.ProvidesServices](r.modules) { + providers = append(providers, module.ServiceProviders()...) + } + + if len(providers) > 0 { + r.providerRepository.Add(providers) + } +} + +// registerModuleResources registers module views under "/", after providers +// boot and before configureViews compiles them. +func (r *Application) registerModuleResources() { + modules := modular.Providing[contractsmodular.ProvidesViews](r.modules) + if len(modules) == 0 { + return + } + + viewFacade := r.MakeView() + if viewFacade == nil { + color.Errorln("View facade not found, please install it first: ./artisan package:install View") + return + } + + for _, module := range r.modules { + p, ok := module.(contractsmodular.ProvidesViews) + if !ok { + continue + } + + fsys := p.Views() + if fsys == nil { + // LoadViewsFromFS panics on a nil filesystem. + color.Errorln(errors.ModularViewsFSRequired.Args(module.Name())) + continue } + + viewFacade.LoadViewsFromFS(modular.NewPrefixFS(module.Name(), fsys), ".") } } diff --git a/foundation/application_modules_test.go b/foundation/application_modules_test.go index 95d3e0414..c8f5b7002 100644 --- a/foundation/application_modules_test.go +++ b/foundation/application_modules_test.go @@ -1,9 +1,34 @@ package foundation import ( + "io/fs" + "net/http" + "slices" + "testing/fstest" + + "github.com/stretchr/testify/mock" + "github.com/goravel/framework/contracts/binding" + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/database/schema" + "github.com/goravel/framework/contracts/database/seeder" + "github.com/goravel/framework/contracts/event" + "github.com/goravel/framework/contracts/foundation" + contractsconfiguration "github.com/goravel/framework/contracts/foundation/configuration" + contractshttp "github.com/goravel/framework/contracts/http" contractsmodular "github.com/goravel/framework/contracts/modular" + "github.com/goravel/framework/contracts/queue" + "github.com/goravel/framework/contracts/route" + "github.com/goravel/framework/contracts/schedule" mocksconfig "github.com/goravel/framework/mocks/config" + mocksconsole "github.com/goravel/framework/mocks/console" + mocksschema "github.com/goravel/framework/mocks/database/schema" + mocksseeder "github.com/goravel/framework/mocks/database/seeder" + mocksevent "github.com/goravel/framework/mocks/event" + mocksqueue "github.com/goravel/framework/mocks/queue" + mocksroute "github.com/goravel/framework/mocks/route" + mocksschedule "github.com/goravel/framework/mocks/schedule" + mocksview "github.com/goravel/framework/mocks/view" ) // testModule has only a name; tests embed it to add capabilities. @@ -115,3 +140,332 @@ func (s *ApplicationTestSuite) TestConfigureModules() { s.Nil(s.app.MakeModular()) }) } + +// richModule implements every capability with test-set values. +type richModule struct { + testModule + config map[string]any + translation fs.FS + views fs.FS + middleware []contractshttp.Middleware + providers []foundation.ServiceProvider + commands []console.Command + migrations []schema.Migration + seeders []seeder.Seeder + jobs []queue.Job + schedule []schedule.Event + events map[event.Event][]event.Listener + routed *[]string +} + +func (m *richModule) Config() map[string]any { return m.config } +func (m *richModule) Translations() fs.FS { return m.translation } +func (m *richModule) Views() fs.FS { return m.views } +func (m *richModule) GlobalMiddleware() []contractshttp.Middleware { return m.middleware } +func (m *richModule) ServiceProviders() []foundation.ServiceProvider { return m.providers } +func (m *richModule) Commands() []console.Command { return m.commands } +func (m *richModule) Migrations() []schema.Migration { return m.migrations } +func (m *richModule) Seeders() []seeder.Seeder { return m.seeders } +func (m *richModule) Jobs() []queue.Job { return m.jobs } +func (m *richModule) Schedule() []schedule.Event { return m.schedule } +func (m *richModule) Events() map[event.Event][]event.Listener { return m.events } +func (m *richModule) Routes(route.Router) { *m.routed = append(*m.routed, m.name+":web") } +func (m *richModule) ApiRoutes(route.Router) { *m.routed = append(*m.routed, m.name+":api") } + +// withModules points the builder at modules and runs configureModules. +func (s *ApplicationTestSuite) withModules(builder *ApplicationBuilder, modules ...contractsmodular.Module) { + builder.modules = func() []contractsmodular.Module { return modules } + s.app.builder = builder + s.app.configureModules() +} + +type fakeMiddleware struct{ signature string } + +func (m *fakeMiddleware) Handle(contractshttp.Context) {} +func (m *fakeMiddleware) Signature() string { return m.signature } + +func middlewareSignatures(middleware []contractshttp.Middleware) []string { + var out []string + for _, m := range middleware { + out = append(out, m.Signature()) + } + + return out +} + +func (s *ApplicationTestSuite) TestConfigureModuleConfig() { + s.Run("module config and translations, app lang FS kept as fallback", func() { + s.SetupTest() + appLang := fstest.MapFS{"en/app.json": {Data: []byte(`{"name":"App"}`)}} + blogLang := fstest.MapFS{"en/messages.json": {Data: []byte(`{"title":"Blog"}`)}} + + mockConfig := mocksconfig.NewConfig(s.T()) + mockConfig.EXPECT().GetString("modular.path", "modules").Return("modules").Once() + mockConfig.EXPECT().Add("modules.blog", map[string]any{"enabled": true}).Return().Once() + mockConfig.EXPECT().Add("modules.shop", map[string]any{"currency": "EUR"}).Return().Once() + mockConfig.EXPECT().Get("app.lang_fs").Return(appLang).Once() + mockConfig.EXPECT().Add("app.lang_fs", mock.MatchedBy(func(fsys fs.FS) bool { + module, err := fs.ReadFile(fsys, "en/blog/messages.json") + app, appErr := fs.ReadFile(fsys, "en/app.json") + + return err == nil && appErr == nil && string(module) == `{"title":"Blog"}` && string(app) == `{"name":"App"}` + })).Return().Once() + s.app.Instance(binding.Config, mockConfig) + + s.withModules(NewApplicationBuilder(s.app), + &richModule{testModule: testModule{"blog"}, config: map[string]any{"enabled": true}, translation: blogLang}, + &richModule{testModule: testModule{"shop"}, config: map[string]any{"currency": "EUR"}}, + ) + s.app.configureModuleConfig() + }) + + s.Run("without modules the config facade is not touched", func() { + s.SetupTest() + s.withModules(NewApplicationBuilder(s.app), &testModule{"plain"}) + s.app.configureModuleConfig() // no config bound: must not be needed + }) +} + +func (s *ApplicationTestSuite) TestConfigureServiceProviders_Modules() { + s.SetupTest() + s.app.providerRepository = NewProviderRepository() + appProvider := &AServiceProvider{} + moduleProvider := &BServiceProvider{} + + builder := NewApplicationBuilder(s.app) + builder.configuredServiceProviders = func() []foundation.ServiceProvider { return []foundation.ServiceProvider{appProvider} } + s.withModules(builder, &richModule{testModule: testModule{"blog"}, providers: []foundation.ServiceProvider{moduleProvider}}) + s.app.configureServiceProviders() + + repository := s.app.providerRepository.(*ProviderRepository) + s.Equal([]foundation.ServiceProvider{appProvider, moduleProvider}, repository.providers) +} + +func (s *ApplicationTestSuite) TestConfigureMiddleware_Modules() { + driver := &fakeMiddleware{"driver"} + module := &fakeMiddleware{"module"} + app := &fakeMiddleware{"app"} + + s.Run("module middleware without WithMiddleware", func() { + s.SetupTest() + mockRoute := mocksroute.NewRoute(s.T()) + mockRoute.EXPECT().GetGlobalMiddleware().Return([]contractshttp.Middleware{driver}).Once() + mockRoute.EXPECT().SetGlobalMiddleware(mock.MatchedBy(func(m []contractshttp.Middleware) bool { + return slices.Equal(middlewareSignatures(m), []string{"driver", "module"}) + })).Return().Once() + s.app.Instance(binding.Route, mockRoute) + + s.withModules(NewApplicationBuilder(s.app), &richModule{testModule: testModule{"blog"}, middleware: []contractshttp.Middleware{module}}) + s.app.configureMiddleware() + }) + + s.Run("WithMiddleware sees module middleware and runs last", func() { + s.SetupTest() + mockRoute := mocksroute.NewRoute(s.T()) + mockRoute.EXPECT().GetGlobalMiddleware().Return([]contractshttp.Middleware{driver}).Once() + mockRoute.EXPECT().SetGlobalMiddleware(mock.MatchedBy(func(m []contractshttp.Middleware) bool { + return slices.Equal(middlewareSignatures(m), []string{"driver", "module", "app"}) + })).Return().Once() + s.app.Instance(binding.Route, mockRoute) + + builder := NewApplicationBuilder(s.app) + builder.middleware = func(handler contractsconfiguration.Middleware) { + s.Len(handler.GetGlobalMiddleware(), 2) + handler.Append(app) + } + s.withModules(builder, &richModule{testModule: testModule{"blog"}, middleware: []contractshttp.Middleware{module}}) + s.app.configureMiddleware() + }) +} + +func (s *ApplicationTestSuite) TestRegisterModuleResources() { + s.Run("views are registered under the module name", func() { + s.SetupTest() + mockView := mocksview.NewView(s.T()) + mockView.EXPECT().LoadViewsFromFS(mock.MatchedBy(func(fsys fs.FS) bool { + _, err := fs.Stat(fsys, "blog/index.tmpl") + + return err == nil + }), ".").Return().Once() + s.app.Instance(binding.View, mockView) + + s.withModules(NewApplicationBuilder(s.app), + &richModule{testModule: testModule{"blog"}, views: fstest.MapFS{"index.tmpl": {Data: []byte("x")}}}, + &richModule{testModule: testModule{"broken"}}, // nil views: reported, not registered + &testModule{"plain"}, + ) + s.app.registerModuleResources() + }) + + s.Run("without view modules the view facade is not needed", func() { + s.SetupTest() + s.withModules(NewApplicationBuilder(s.app), &testModule{"plain"}) + s.app.registerModuleResources() + }) +} + +func (s *ApplicationTestSuite) TestConfigureRegistrations_Modules() { + s.SetupTest() + appCommand, moduleCommand := &fakeCommand{signature: "app:run"}, &fakeCommand{signature: "blog:run"} + appMigration, moduleMigration := mocksschema.NewMigration(s.T()), mocksschema.NewMigration(s.T()) + appSeeder, moduleSeeder := mocksseeder.NewSeeder(s.T()), mocksseeder.NewSeeder(s.T()) + appJob, moduleJob := mocksqueue.NewJob(s.T()), mocksqueue.NewJob(s.T()) + appEvent, moduleEvent := mocksschedule.NewEvent(s.T()), mocksschedule.NewEvent(s.T()) + + mockArtisan := mocksconsole.NewArtisan(s.T()) + mockArtisan.EXPECT().Register([]console.Command{appCommand, moduleCommand}).Return().Once() + s.app.Instance(binding.Artisan, mockArtisan) + mockSchema := mocksschema.NewSchema(s.T()) + mockSchema.EXPECT().Register([]schema.Migration{appMigration, moduleMigration}).Return().Once() + s.app.Instance(binding.Schema, mockSchema) + mockSeeder := mocksseeder.NewFacade(s.T()) + mockSeeder.EXPECT().Register([]seeder.Seeder{appSeeder, moduleSeeder}).Return().Once() + s.app.Instance(binding.Seeder, mockSeeder) + mockQueue := mocksqueue.NewQueue(s.T()) + mockQueue.EXPECT().Register([]queue.Job{appJob, moduleJob}).Return().Once() + s.app.Instance(binding.Queue, mockQueue) + mockSchedule := mocksschedule.NewSchedule(s.T()) + mockSchedule.EXPECT().Register([]schedule.Event{appEvent, moduleEvent}).Return().Once() + s.app.Instance(binding.Schedule, mockSchedule) + + builder := NewApplicationBuilder(s.app) + builder.commands = func() []console.Command { return []console.Command{appCommand} } + builder.migrations = func() []schema.Migration { return []schema.Migration{appMigration} } + builder.seeders = func() []seeder.Seeder { return []seeder.Seeder{appSeeder} } + builder.jobs = func() []queue.Job { return []queue.Job{appJob} } + builder.schedule = func() []schedule.Event { return []schedule.Event{appEvent} } + s.withModules(builder, &richModule{ + testModule: testModule{"blog"}, + commands: []console.Command{moduleCommand}, + migrations: []schema.Migration{moduleMigration}, + seeders: []seeder.Seeder{moduleSeeder}, + jobs: []queue.Job{moduleJob}, + schedule: []schedule.Event{moduleEvent}, + }) + + s.app.configureCommands() + s.app.configureMigrations() + s.app.configureSeeders() + s.app.configureJobs() + s.app.configureSchedule() +} + +func (s *ApplicationTestSuite) TestConfigureRegistrations_ModulesOnly() { + s.SetupTest() + moduleCommand := &fakeCommand{signature: "blog:run"} + mockArtisan := mocksconsole.NewArtisan(s.T()) + mockArtisan.EXPECT().Register([]console.Command{moduleCommand}).Return().Once() + s.app.Instance(binding.Artisan, mockArtisan) + + s.withModules(NewApplicationBuilder(s.app), &richModule{testModule: testModule{"blog"}, commands: []console.Command{moduleCommand}}) + s.app.configureCommands() +} + +func (s *ApplicationTestSuite) TestConfigureEventListeners_Modules() { + s.SetupTest() + shared, moduleOnly := mocksevent.NewEvent(s.T()), mocksevent.NewEvent(s.T()) + appListener, moduleListener, otherListener := mocksevent.NewListener(s.T()), mocksevent.NewListener(s.T()), mocksevent.NewListener(s.T()) + + mockEvent := mocksevent.NewInstance(s.T()) + mockEvent.EXPECT().Register(map[event.Event][]event.Listener{ + shared: {appListener, moduleListener}, + moduleOnly: {otherListener}, + }).Return().Once() + s.app.Instance(binding.Event, mockEvent) + + builder := NewApplicationBuilder(s.app) + builder.eventToListeners = func() map[event.Event][]event.Listener { + return map[event.Event][]event.Listener{shared: {appListener}} + } + s.withModules(builder, &richModule{testModule: testModule{"blog"}, events: map[event.Event][]event.Listener{ + shared: {moduleListener}, + moduleOnly: {otherListener}, + }}) + s.app.configureEventListeners() +} + +func (s *ApplicationTestSuite) TestConfigureRoutes_Modules() { + s.Run("module routes go into the configured groups, after the app's", func() { + s.SetupTest() + var routed []string + webMiddleware := &fakeMiddleware{"web"} + + mockConfig := mocksconfig.NewConfig(s.T()) + mockConfig.EXPECT().GetString("modular.path", "modules").Return("modules").Once() + mockConfig.EXPECT().GetString("modular.routes.web.prefix", "").Return("").Once() + mockConfig.EXPECT().Get("modular.routes.web.middleware").Return([]contractshttp.Middleware{webMiddleware}).Once() + mockConfig.EXPECT().GetString("modular.routes.api.prefix", "api").Return("api").Once() + mockConfig.EXPECT().Get("modular.routes.api.middleware").Return(nil).Once() + s.app.Instance(binding.Config, mockConfig) + + webRouter, apiRouter := mocksroute.NewRouter(s.T()), mocksroute.NewRouter(s.T()) + mockRoute := mocksroute.NewRoute(s.T()) + mockRoute.EXPECT().Prefix("").Return(webRouter).Twice() + webRouter.EXPECT().Middleware(mock.Anything).Return(webRouter).Twice() + webRouter.EXPECT().Group(mock.Anything).Run(func(handler route.GroupFunc) { handler(webRouter) }).Return().Twice() + mockRoute.EXPECT().Prefix("api").Return(apiRouter).Once() + apiRouter.EXPECT().Middleware().Return(apiRouter).Once() + apiRouter.EXPECT().Group(mock.Anything).Run(func(handler route.GroupFunc) { handler(apiRouter) }).Return().Once() + s.app.Instance(binding.Route, mockRoute) + + builder := NewApplicationBuilder(s.app) + builder.routes = func() { routed = append(routed, "app") } + s.withModules(builder, + &richModule{testModule: testModule{"blog"}, routed: &routed}, + &webOnlyModule{testModule{"shop"}, &routed}, + ) + s.app.configureRoutes() + + s.Equal([]string{"app", "blog:web", "shop:web", "blog:api"}, routed) + }) + + s.Run("without module routes or static files the route facade is not needed", func() { + s.SetupTest() + s.withModules(NewApplicationBuilder(s.app), &testModule{"plain"}) + s.app.configureRoutes() + }) +} + +type webOnlyModule struct { + testModule + routed *[]string +} + +func (m *webOnlyModule) Routes(route.Router) { *m.routed = append(*m.routed, m.name+":web") } + +type staticModule struct { + testModule + static map[string]contractsmodular.Path +} + +func (m *staticModule) Static() map[string]contractsmodular.Path { return m.static } + +func (s *ApplicationTestSuite) TestConfigureRoutes_ModuleStatic() { + s.SetupTest() + public := fstest.MapFS{"public/robots.txt": {Data: []byte("x")}} + + mockRoute := mocksroute.NewRoute(s.T()) + mockRoute.EXPECT().StaticFS("/blog/static", mock.MatchedBy(func(fsys http.FileSystem) bool { + file, err := fsys.Open("/robots.txt") + if err != nil { + return false + } + _ = file.Close() + _, err = fsys.Open("/") + + return err != nil // directories are not listed + })).Return(mocksroute.NewAction(s.T())).Once() + s.app.Instance(binding.Route, mockRoute) + + s.withModules(NewApplicationBuilder(s.app), + &staticModule{testModule: testModule{"blog"}, static: map[string]contractsmodular.Path{ + "blog/static/": {FS: public, Dir: "public"}, + "/": {FS: public, Dir: "public"}, // invalid prefix: reported + "/blog/secret": {FS: public, Dir: "../outside"}, // escapes the module: reported + }}, + &staticModule{testModule: testModule{"shop"}, static: map[string]contractsmodular.Path{ + "/blog/static": {FS: public, Dir: "public"}, // taken by blog: reported + }}, + ) + s.app.configureRoutes() +} From e5aa5385e25f59e533743c2aebb11839ab75a3b7 Mon Sep 17 00:00:00 2001 From: Saad Date: Wed, 11 Nov 2026 11:29:53 +0600 Subject: [PATCH 09/29] feat(packages): add module helpers to modify and match - match.Modules() finds the Modules() function returning []modular.Module. - modify.AddModule / RemoveModule edit bootstrap/modules.go like the other With* helpers; an aliased import ("blogposts
/modules/blog-posts") is supported for kebab-case directories. - modify.InitModules() creates an empty bootstrap/modules.go and adds WithModules(Modules) to foundation.Setup(). It goes through the new withSliceHandler.EnsureWithMethod, which edits app.go before writing the helper file and checks that the edit landed, so a chain that is not a statement of its own (builder := foundation.Setup()...) returns PackageSetupNotFound and leaves nothing behind. - modify.ModulesStub() exposes the empty modules.go. --- errors/list.go | 2 + packages/match/helper.go | 17 ++++++ packages/match/helper_test.go | 20 +++++++ packages/modify/stubs.go | 13 ++++ packages/modify/utils.go | 34 +++++++++++ packages/modify/utils_test.go | 70 ++++++++++++++++++++++ packages/modify/with_slice_actions.go | 38 ++++++++++++ packages/modify/with_slice_actions_test.go | 65 ++++++++++++++++++++ 8 files changed, 259 insertions(+) diff --git a/errors/list.go b/errors/list.go index 247c043f6..1ed2ca47a 100644 --- a/errors/list.go +++ b/errors/list.go @@ -275,8 +275,10 @@ var ( PackageGoModDownloadFailed = New("failed to download go.mod dependencies: %s") PackageJobsFileExists = New("jobs.go already exists but WithJobs is not registered in foundation.Setup(). The jobs.go file should only be created when adding WithJobs to Setup()") PackageMigrationsFileExists = New("migrations.go already exists but WithMigrations is not registered in foundation.Setup(). The migrations.go file should only be created when adding WithMigrations to Setup()") + PackageModulesFileExists = New("modules.go already exists but WithModules is not registered in foundation.Setup(). Add .WithModules(Modules) to Setup()") PackageProvidersFileExists = New("providers.go already exists but WithProviders is not registered in foundation.Setup(). The providers.go file should only be created when adding WithProviders to Setup()") PackageRulesFileExists = New("rules.go already exists but WithRules is not registered in foundation.Setup(). The rules.go file should only be created when adding WithRules to Setup()") + PackageSetupNotFound = New("foundation.Setup() chain not found as a statement of the Boot function in bootstrap/app.go") PackageSeedersFileExists = New("seeders.go already exists but WithSeeders is not registered in foundation.Setup(). The seeders.go file should only be created when adding WithSeeders to Setup()") PluralizerLanguageNotFound = New("language %s not found").SetModule(ModulePluralizer) diff --git a/packages/match/helper.go b/packages/match/helper.go index 74141510b..8a2faa7fe 100644 --- a/packages/match/helper.go +++ b/packages/match/helper.go @@ -231,6 +231,23 @@ func Migrations() []match.GoNode { } } +// Modules matches the Modules() function returning a []modular.Module literal. +func Modules() []match.GoNode { + return []match.GoNode{ + Func(Ident("Modules")), + TypeOf(&dst.ReturnStmt{}), + CompositeLit( + ArrayType( + SelectorExpr( + Ident("modular"), + Ident("Module"), + ), + AnyNode(), + ), + ), + } +} + // Providers matches the Providers() function that returns a slice of service providers. // It looks for a function returning []foundation.ServiceProvider composite literals. // This is the recommended way to register service providers. diff --git a/packages/match/helper_test.go b/packages/match/helper_test.go index 1c7fc17d5..de880c8b4 100644 --- a/packages/match/helper_test.go +++ b/packages/match/helper_test.go @@ -96,6 +96,12 @@ func (kernel Kernel) Seeders() []seeder.Seeder { return []seeder.Seeder{ &seeders.DatabaseSeeder{}, } +} + +func Modules() []modular.Module { + return []modular.Module{ + &blog.Module{}, + } }`) s.Require().NoError(err) s.validation, err = decorator.Parse(`package providers @@ -309,6 +315,20 @@ func (s *MatchHelperTestSuite) TestHelper() { s.Len(node.(*dst.CompositeLit).Elts, 2) }, }, + { + name: "match modules", + file: s.database, + matchers: Modules(), + assert: func(node dst.Node) { + s.True(CompositeLit(EqualNode(&dst.ArrayType{ + Elt: &dst.SelectorExpr{ + X: &dst.Ident{Name: "modular"}, + Sel: &dst.Ident{Name: "Module"}, + }, + })).MatchNode(node)) + s.Len(node.(*dst.CompositeLit).Elts, 1) + }, + }, { name: "match seeders", file: s.database, diff --git a/packages/modify/stubs.go b/packages/modify/stubs.go index 24a9fa96a..f73e23412 100644 --- a/packages/modify/stubs.go +++ b/packages/modify/stubs.go @@ -46,6 +46,19 @@ func Migrations() []schema.Migration { return replaceDummyPackage(content) } +func modules() string { + content := `package DummyPackage + +import "github.com/goravel/framework/contracts/modular" + +func Modules() []modular.Module { + return []modular.Module{} +} +` + + return replaceDummyPackage(content) +} + func providers() string { content := `package DummyPackage diff --git a/packages/modify/utils.go b/packages/modify/utils.go index f35e974dd..42fe74d49 100644 --- a/packages/modify/utils.go +++ b/packages/modify/utils.go @@ -95,6 +95,11 @@ func AddMigration(pkg, migration string) error { return handler.AddItem(pkg, migration) } +// AddModule adds module to bootstrap/modules.go, wiring WithModules(Modules) when missing. +func AddModule(pkg, module string) error { + return newWithSliceHandler(moduleConfig()).AddItem(pkg, module) +} + // AddProvider adds service provider to the foundation.Setup() chain in the Boot function. func AddProvider(pkg, provider string) error { config := withSliceConfig{ @@ -211,6 +216,30 @@ func ExprIndex(x []dst.Expr, y dst.Expr) int { }) } +// InitModules creates bootstrap/modules.go and wires WithModules(Modules), with no module listed. +func InitModules() error { + return newWithSliceHandler(moduleConfig()).EnsureWithMethod() +} + +// ModulesStub is the content of an empty bootstrap/modules.go. +func ModulesStub() string { + return modules() +} + +func moduleConfig() withSliceConfig { + return withSliceConfig{ + fileName: "modules.go", + withMethodName: "WithModules", + helperFuncName: "Modules", + typePackage: "modular", + typeName: "Module", + typeImportPath: "github.com/goravel/framework/contracts/modular", + fileExistsError: errors.PackageModulesFileExists, + stubTemplate: modules, + matcherFunc: match.Modules, + } +} + // IsUsingImport checks if an imported package is actually used in the file. // It inspects the AST for selector expressions that reference the package. // @@ -368,6 +397,11 @@ func RemoveMigration(pkg, migration string) error { return handler.RemoveItem(pkg, migration) } +// RemoveModule removes module from the Modules() list. +func RemoveModule(pkg, module string) error { + return newWithSliceHandler(moduleConfig()).RemoveItem(pkg, module) +} + // RemoveProvider removes a service provider from the foundation.Setup() chain in the Boot function. func RemoveProvider(pkg, provider string) error { config := withSliceConfig{ diff --git a/packages/modify/utils_test.go b/packages/modify/utils_test.go index d35b2d223..636220b8c 100644 --- a/packages/modify/utils_test.go +++ b/packages/modify/utils_test.go @@ -4319,3 +4319,73 @@ var _ = map[string]any{ `, buf.String()) } + +func TestModules(t *testing.T) { + bootstrapDir := support.Config.Paths.Bootstrap + appFile := filepath.Join(bootstrapDir, "app.go") + modulesFile := filepath.Join(bootstrapDir, "modules.go") + defer func() { + assert.NoError(t, supportfile.Remove(bootstrapDir)) + }() + + assert.NoError(t, supportfile.PutContent(appFile, `package bootstrap + +import ( + contractsfoundation "github.com/goravel/framework/contracts/foundation" + "github.com/goravel/framework/foundation" + "goravel/config" +) + +func Boot() contractsfoundation.Application { + return foundation.Setup().WithConfig(config.Boot).Start() +} +`)) + + assert.NoError(t, InitModules()) + content, err := supportfile.GetContent(modulesFile) + assert.NoError(t, err) + assert.Equal(t, ModulesStub(), content) + assert.True(t, supportfile.Contain(appFile, "WithModules(Modules)")) + + assert.NoError(t, AddModule("goravel/modules/blog", "&blog.Module{}")) + assert.NoError(t, AddModule("blogposts goravel/modules/blog-posts", "&blogposts.Module{}")) + // Adding a listed module again is a no-op. + assert.NoError(t, AddModule("goravel/modules/blog", "&blog.Module{}")) + + content, err = supportfile.GetContent(modulesFile) + assert.NoError(t, err) + assert.Equal(t, `package bootstrap + +import ( + "github.com/goravel/framework/contracts/modular" + + "goravel/modules/blog" + blogposts "goravel/modules/blog-posts" +) + +func Modules() []modular.Module { + return []modular.Module{ + &blog.Module{}, + &blogposts.Module{}, + } +} +`, content) + + assert.NoError(t, RemoveModule("goravel/modules/blog", "&blog.Module{}")) + content, err = supportfile.GetContent(modulesFile) + assert.NoError(t, err) + assert.Equal(t, `package bootstrap + +import ( + "github.com/goravel/framework/contracts/modular" + + blogposts "goravel/modules/blog-posts" +) + +func Modules() []modular.Module { + return []modular.Module{ + &blogposts.Module{}, + } +} +`, content) +} diff --git a/packages/modify/with_slice_actions.go b/packages/modify/with_slice_actions.go index edd70610b..ff8da0921 100644 --- a/packages/modify/with_slice_actions.go +++ b/packages/modify/with_slice_actions.go @@ -11,6 +11,7 @@ import ( contractsmatch "github.com/goravel/framework/contracts/packages/match" "github.com/goravel/framework/contracts/packages/modify" + "github.com/goravel/framework/errors" "github.com/goravel/framework/packages/match" supportfile "github.com/goravel/framework/support/file" "github.com/goravel/framework/support/path" @@ -179,6 +180,43 @@ func (r *withSliceHandler) AddItem(pkg, item string) error { return GoFile(r.appFilePath).Find(match.FoundationSetup()).Modify(r.setupInline(item)).Apply() } +// EnsureWithMethod wires WithMethod(HelperFunc) into foundation.Setup() with an empty helper file. +// It edits app.go first, so a failed match leaves no helper file behind. +func (r *withSliceHandler) EnsureWithMethod() error { + hasFoundationSetup, err := r.containsFoundationSetupInAppFile() + if err != nil { + return err + } + if !hasFoundationSetup { + return errors.PackageSetupNotFound + } + + withMethodExists, err := r.checkWithMethodExists() + if err != nil { + return err + } + if withMethodExists { + return nil + } + if r.fileExists { + return r.config.fileExistsError + } + + if err := GoFile(r.appFilePath).Find(match.FoundationSetup()).Modify(r.setupWithFunction()).Apply(); err != nil { + return err + } + + // A chain that is not a statement of its own (builder := foundation.Setup()...) is left untouched. + if withMethodExists, err = r.checkWithMethodExists(); err != nil { + return err + } + if !withMethodExists { + return errors.PackageSetupNotFound + } + + return r.createFile() +} + // RemoveItem removes an item from the slice in foundation.Setup() chain. // // In always-inline mode: Removes the item from the inline array in app.go and cleans up imports. diff --git a/packages/modify/with_slice_actions_test.go b/packages/modify/with_slice_actions_test.go index 9b8b684a7..453d4a8ee 100644 --- a/packages/modify/with_slice_actions_test.go +++ b/packages/modify/with_slice_actions_test.go @@ -344,6 +344,71 @@ func Boot() contractsfoundation.Application { s.Contains(appResult, "&commands.NewCommand{}") } +func (s *WithSliceHandlerTestSuite) TestEnsureWithMethod() { + modulesFile := filepath.Join(s.bootstrapDir, "modules.go") + app := func(body string) string { + return `package bootstrap + +import ( + contractsfoundation "github.com/goravel/framework/contracts/foundation" + "github.com/goravel/framework/foundation" + "goravel/config" +) + +func Boot() contractsfoundation.Application { +` + body + ` +} +` + } + + s.Run("creates the helper file and wires WithMethod", func() { + s.Require().NoError(supportfile.PutContent(s.appFile, app("\treturn foundation.Setup().WithConfig(config.Boot).Start()"))) + + s.NoError(newWithSliceHandler(moduleConfig()).EnsureWithMethod()) + + appResult, err := supportfile.GetContent(s.appFile) + s.NoError(err) + s.Contains(appResult, "WithModules(Modules)") + + modulesResult, err := supportfile.GetContent(modulesFile) + s.NoError(err) + s.Equal(modules(), modulesResult) + + // A second run changes nothing. + s.NoError(newWithSliceHandler(moduleConfig()).EnsureWithMethod()) + again, err := supportfile.GetContent(s.appFile) + s.NoError(err) + s.Equal(appResult, again) + s.NoError(supportfile.Remove(s.bootstrapDir)) + }) + + s.Run("helper file exists without WithMethod", func() { + s.Require().NoError(supportfile.PutContent(s.appFile, app("\treturn foundation.Setup().WithConfig(config.Boot).Start()"))) + s.Require().NoError(supportfile.PutContent(modulesFile, modules())) + + s.Equal(errors.PackageModulesFileExists, newWithSliceHandler(moduleConfig()).EnsureWithMethod()) + s.NoError(supportfile.Remove(s.bootstrapDir)) + }) + + s.Run("no foundation.Setup()", func() { + s.Require().NoError(supportfile.PutContent(s.appFile, app("\treturn nil"))) + + s.Equal(errors.PackageSetupNotFound, newWithSliceHandler(moduleConfig()).EnsureWithMethod()) + s.False(supportfile.Exists(modulesFile)) + s.NoError(supportfile.Remove(s.bootstrapDir)) + }) + + s.Run("assigned chain is left untouched and leaves no helper file", func() { + s.Require().NoError(supportfile.PutContent(s.appFile, app("\tbuilder := foundation.Setup().WithConfig(config.Boot)\n\n\treturn builder.Start()"))) + + err := newWithSliceHandler(moduleConfig()).EnsureWithMethod() + + s.Equal(errors.PackageSetupNotFound, err) + s.False(supportfile.Exists(modulesFile)) + s.False(supportfile.Contain(s.appFile, "WithModules")) + }) +} + func (s *WithSliceHandlerTestSuite) TestCheckWithMethodExists() { config := withSliceConfig{ fileName: "commands.go", From 227870e7b74e20198aa68d3f9c3daa9e34633387 Mon Sep 17 00:00:00 2001 From: Saad Date: Wed, 11 Nov 2026 11:35:21 +0600 Subject: [PATCH 10/29] feat(modular): add modular:init artisan modular:init prepares an application for modules and can be run again: every target is reported as CREATED, EXISTS or MANUAL. - /.gitkeep (default modules/) - config/modular.go with modular.path and the modular.routes.web/api groups, using the app's facades package or the framework's when the app has none - bootstrap/modules.go and .WithModules(Modules) in foundation.Setup(). When the chain cannot be edited (it is assigned to a variable), modules.go is still written and the row is MANUAL with the line to add; the command does not fail. It needs bootstrap/app.go to use foundation.Setup() and is registered with the default commands, so it works before the application has any module. --- errors/list.go | 17 +-- foundation/application.go | 2 + modular/console/console.go | 52 +++++++++ modular/console/init_command.go | 104 +++++++++++++++++ modular/console/init_command_test.go | 166 +++++++++++++++++++++++++++ modular/console/stubs.go | 36 ++++++ 6 files changed, 369 insertions(+), 8 deletions(-) create mode 100644 modular/console/console.go create mode 100644 modular/console/init_command.go create mode 100644 modular/console/init_command_test.go create mode 100644 modular/console/stubs.go diff --git a/errors/list.go b/errors/list.go index 1ed2ca47a..6778a5b68 100644 --- a/errors/list.go +++ b/errors/list.go @@ -217,14 +217,15 @@ var ( MigrationResetFailed = New("migration reset failed: %v") MigrationRollbackFailed = New("migration rollback failed: %v") - ModularModuleDuplicate = New("module %q is registered more than once").SetModule(ModuleModular) - ModularModulePathInvalid = New("module %s: directory %q must be a relative path inside the project").SetModule(ModuleModular) - ModularModulePathUnknown = New("module %s: cannot derive its directory from package %q, implement Path() to set it").SetModule(ModuleModular) - ModularStaticDirInvalid = New("module %s: static directory %q must be a relative path inside the module").SetModule(ModuleModular) - ModularStaticDirUnreadable = New("module %s: static directory %q is not a readable directory: %v").SetModule(ModuleModular) - ModularStaticDuplicate = New("static path %s of module %s is already served by module %s").SetModule(ModuleModular) - ModularStaticPrefixInvalid = New("module %s: static prefix %q is invalid, use a path below /").SetModule(ModuleModular) - ModularViewsFSRequired = New("module %s returned a nil views filesystem").SetModule(ModuleModular) + ModularModuleDuplicate = New("module %q is registered more than once").SetModule(ModuleModular) + ModularModulePathInvalid = New("module %s: directory %q must be a relative path inside the project").SetModule(ModuleModular) + ModularModulePathUnknown = New("module %s: cannot derive its directory from package %q, implement Path() to set it").SetModule(ModuleModular) + ModularRequiresBootstrapSetup = New("%s needs bootstrap/app.go to build the application with foundation.Setup()").SetModule(ModuleModular) + ModularStaticDirInvalid = New("module %s: static directory %q must be a relative path inside the module").SetModule(ModuleModular) + ModularStaticDirUnreadable = New("module %s: static directory %q is not a readable directory: %v").SetModule(ModuleModular) + ModularStaticDuplicate = New("static path %s of module %s is already served by module %s").SetModule(ModuleModular) + ModularStaticPrefixInvalid = New("module %s: static prefix %q is invalid, use a path below /").SetModule(ModuleModular) + ModularViewsFSRequired = New("module %s returned a nil views filesystem").SetModule(ModuleModular) NotificationChannelNotFound = New("notification channel not found: %s").SetModule(ModuleNotification) NotificationChannelNotQueueable = New("notification channel %q does not support queued dispatch (does not implement ResolvableChannel)").SetModule(ModuleNotification) diff --git a/foundation/application.go b/foundation/application.go index 1588a5c33..65b2e0a48 100644 --- a/foundation/application.go +++ b/foundation/application.go @@ -43,6 +43,7 @@ import ( "github.com/goravel/framework/foundation/console" "github.com/goravel/framework/foundation/json" "github.com/goravel/framework/modular" + modularconsole "github.com/goravel/framework/modular/console" "github.com/goravel/framework/process" "github.com/goravel/framework/support" "github.com/goravel/framework/support/carbon" @@ -1033,6 +1034,7 @@ func (r *Application) defaultCommands() []contractsconsole.Command { console.NewPackageInstallCommand(binding.Bindings, r.MakeProcess(), r.Json()), console.NewPackageUninstallCommand(binding.Bindings, r.MakeProcess(), r.Json()), console.NewVendorPublishCommand(r.publishes, r.publishGroups), + modularconsole.NewInitCommand(r.MakeConfig()), } if r.MakeRoute() != nil { diff --git a/modular/console/console.go b/modular/console/console.go new file mode 100644 index 000000000..40a3eb03c --- /dev/null +++ b/modular/console/console.go @@ -0,0 +1,52 @@ +// Package console holds the module Artisan commands. It never imports foundation. +package console + +import ( + "github.com/goravel/framework/contracts/config" + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/modular" + "github.com/goravel/framework/packages" + "github.com/goravel/framework/support/file" + "github.com/goravel/framework/support/path" +) + +const ( + statusCreated = "CREATED" + statusExists = "EXISTS" + statusManual = "MANUAL" +) + +// modulesDirectory is modular.path, also before config/modular.go exists. +func modulesDirectory(config config.Config) string { + if config == nil { + return modular.DefaultDirectory + } + + return config.GetString("modular.path", modular.DefaultDirectory) +} + +// facadesImport returns the app's facades package, or the framework's when the app has none. +func facadesImport() (importPath, pkg string) { + if file.Exists(path.Facade()) { + facades := packages.Paths().Facades() + + return facades.Import(), facades.Package() + } + + return "github.com/goravel/framework/facades", "facades" +} + +// putIfMissing writes content to file unless it exists and prints a row named label. +func putIfMissing(ctx console.Context, label, filePath, content string) error { + if file.Exists(filePath) { + ctx.TwoColumnDetail(label, statusExists) + + return nil + } + if err := file.PutContent(filePath, content); err != nil { + return err + } + ctx.TwoColumnDetail(label, statusCreated) + + return nil +} diff --git a/modular/console/init_command.go b/modular/console/init_command.go new file mode 100644 index 000000000..dbafb63aa --- /dev/null +++ b/modular/console/init_command.go @@ -0,0 +1,104 @@ +package console + +import ( + "path/filepath" + + "github.com/goravel/framework/contracts/config" + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" + "github.com/goravel/framework/errors" + "github.com/goravel/framework/packages" + "github.com/goravel/framework/packages/modify" + "github.com/goravel/framework/support" + "github.com/goravel/framework/support/env" + "github.com/goravel/framework/support/file" + "github.com/goravel/framework/support/path" +) + +type InitCommand struct { + config config.Config +} + +func NewInitCommand(config config.Config) *InitCommand { + return &InitCommand{config: config} +} + +// Signature The name and signature of the console command. +func (r *InitCommand) Signature() string { + return "modular:init" +} + +// Description The console command description. +func (r *InitCommand) Description() string { + return "Prepare the application for modules" +} + +// Extend The console command extend. +func (r *InitCommand) Extend() command.Extend { + return command.Extend{ + Category: "modular", + } +} + +// Handle Execute the console command. Running it again only reports what exists. +func (r *InitCommand) Handle(ctx console.Context) error { + if !env.IsBootstrapSetup() { + return errors.ModularRequiresBootstrapSetup.Args(r.Signature()) + } + + directory := modulesDirectory(r.config) + if err := putIfMissing(ctx, filepath.ToSlash(filepath.Join(directory, ".gitkeep")), path.Base(directory, ".gitkeep"), ""); err != nil { + return err + } + + facadesImport, facadesPackage := facadesImport() + configStub := Stubs{}.Config(packages.Paths().Config().Package(), facadesImport, facadesPackage) + if err := putIfMissing(ctx, packages.Paths().Config().String("modular.go"), path.Config("modular.go"), configStub); err != nil { + return err + } + + return r.wireModules(ctx) +} + +// wireModules creates bootstrap/modules.go and adds WithModules(Modules) to foundation.Setup(). +func (r *InitCommand) wireModules(ctx console.Context) error { + bootstrap := support.Config.Paths.Bootstrap + modulesLabel := filepath.ToSlash(filepath.Join(bootstrap, "modules.go")) + appLabel := filepath.ToSlash(filepath.Join(bootstrap, "app.go")) + " WithModules(Modules)" + + if file.Contain(path.Bootstrap("app.go"), "WithModules") { + if file.Exists(path.Bootstrap("modules.go")) { + ctx.TwoColumnDetail(modulesLabel, statusExists) + } + ctx.TwoColumnDetail(appLabel, statusExists) + + return nil + } + + err := modify.InitModules() + if err == nil { + ctx.TwoColumnDetail(modulesLabel, statusCreated) + ctx.TwoColumnDetail(appLabel, statusCreated) + + return nil + } + if !isManualWiring(err) { + return err + } + + // The chain could not be edited: keep modules.go ready and leave app.go to the user. + if err := putIfMissing(ctx, modulesLabel, path.Bootstrap("modules.go"), modify.ModulesStub()); err != nil { + return err + } + ctx.TwoColumnDetail(appLabel, statusManual) + ctx.Warning("Add .WithModules(Modules) to foundation.Setup() in " + filepath.ToSlash(filepath.Join(bootstrap, "app.go"))) + + return nil +} + +func isManualWiring(err error) bool { + return errors.Is(err, errors.PackageSetupNotFound) || + errors.Is(err, errors.PackageModulesFileExists) || + errors.Is(err, errors.PackageMatchGoNodeFail) || + errors.Is(err, errors.PackageModifyGoFileFail) +} diff --git a/modular/console/init_command_test.go b/modular/console/init_command_test.go new file mode 100644 index 000000000..89db620d1 --- /dev/null +++ b/modular/console/init_command_test.go @@ -0,0 +1,166 @@ +package console + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/goravel/framework/errors" + mocksconfig "github.com/goravel/framework/mocks/config" + mocksconsole "github.com/goravel/framework/mocks/console" + "github.com/goravel/framework/packages/modify" + "github.com/goravel/framework/support/file" +) + +const chainedApp = `package bootstrap + +import ( + contractsfoundation "github.com/goravel/framework/contracts/foundation" + "github.com/goravel/framework/foundation" +) + +func Boot() contractsfoundation.Application { + return foundation.Setup().Create() +} +` + +const assignedApp = `package bootstrap + +import ( + contractsfoundation "github.com/goravel/framework/contracts/foundation" + "github.com/goravel/framework/foundation" +) + +func Boot() contractsfoundation.Application { + builder := foundation.Setup().WithConfig(nil) + + return builder.Create() +} +` + +type InitCommandTestSuite struct { + suite.Suite +} + +func TestInitCommandTestSuite(t *testing.T) { + suite.Run(t, new(InitCommandTestSuite)) +} + +func (s *InitCommandTestSuite) TearDownTest() { + removeScaffold(s.T()) +} + +func (s *InitCommandTestSuite) TestSignature() { + cmd := NewInitCommand(nil) + + s.Equal("modular:init", cmd.Signature()) + s.Equal("Prepare the application for modules", cmd.Description()) + s.Equal("modular", cmd.Extend().Category) +} + +func (s *InitCommandTestSuite) TestRequiresBootstrapSetup() { + err := NewInitCommand(nil).Handle(mocksconsole.NewContext(s.T())) + + s.Equal(errors.ModularRequiresBootstrapSetup.Args("modular:init"), err) + s.False(file.Exists("modules")) +} + +func (s *InitCommandTestSuite) TestCreatesThenReportsExisting() { + s.Require().NoError(file.PutContent("bootstrap/app.go", chainedApp)) + + ctx := mocksconsole.NewContext(s.T()) + ctx.EXPECT().TwoColumnDetail("modules/.gitkeep", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("config/modular.go", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("bootstrap/modules.go", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("bootstrap/app.go WithModules(Modules)", statusCreated).Once() + + s.NoError(NewInitCommand(nil).Handle(ctx)) + + s.True(file.Exists("modules/.gitkeep")) + s.True(file.Contain("bootstrap/app.go", "WithModules(Modules)")) + modules, err := file.GetContent("bootstrap/modules.go") + s.NoError(err) + s.Equal(modify.ModulesStub(), modules) + + // No app/facades here, so the config file uses the framework facades. + config, err := file.GetContent("config/modular.go") + s.NoError(err) + s.Contains(config, "package config") + s.Contains(config, `"github.com/goravel/framework/facades"`) + s.Contains(config, `facades.Config()`) + s.Contains(config, `config.Add("modular", map[string]any{`) + + app, err := file.GetContent("bootstrap/app.go") + s.NoError(err) + + ctx = mocksconsole.NewContext(s.T()) + ctx.EXPECT().TwoColumnDetail("modules/.gitkeep", statusExists).Once() + ctx.EXPECT().TwoColumnDetail("config/modular.go", statusExists).Once() + ctx.EXPECT().TwoColumnDetail("bootstrap/modules.go", statusExists).Once() + ctx.EXPECT().TwoColumnDetail("bootstrap/app.go WithModules(Modules)", statusExists).Once() + + s.NoError(NewInitCommand(nil).Handle(ctx)) + + again, err := file.GetContent("bootstrap/app.go") + s.NoError(err) + s.Equal(app, again) +} + +func (s *InitCommandTestSuite) TestAppFacadesAndConfiguredPath() { + s.Require().NoError(file.PutContent("bootstrap/app.go", chainedApp)) + s.Require().NoError(file.PutContent("app/facades/config.go", "package facades\n")) + + config := mocksconfig.NewConfig(s.T()) + config.EXPECT().GetString("modular.path", "modules").Return("src/modules").Once() + + ctx := mocksconsole.NewContext(s.T()) + ctx.EXPECT().TwoColumnDetail("src/modules/.gitkeep", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("config/modular.go", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("bootstrap/modules.go", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("bootstrap/app.go WithModules(Modules)", statusCreated).Once() + + s.NoError(NewInitCommand(config).Handle(ctx)) + + s.True(file.Exists("src/modules/.gitkeep")) + s.True(file.Contain("config/modular.go", `"github.com/goravel/framework/app/facades"`)) +} + +func (s *InitCommandTestSuite) TestAssignedChainIsManual() { + s.Require().NoError(file.PutContent("bootstrap/app.go", assignedApp)) + + warning := "Add .WithModules(Modules) to foundation.Setup() in bootstrap/app.go" + ctx := mocksconsole.NewContext(s.T()) + ctx.EXPECT().TwoColumnDetail("modules/.gitkeep", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("config/modular.go", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("bootstrap/modules.go", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("bootstrap/app.go WithModules(Modules)", statusManual).Once() + ctx.EXPECT().Warning(warning).Once() + + s.NoError(NewInitCommand(nil).Handle(ctx)) + + s.True(file.Exists("bootstrap/modules.go")) + app, err := file.GetContent("bootstrap/app.go") + s.NoError(err) + s.Equal(assignedApp, app) + + // modules.go without WithModules: the same manual row again. + ctx = mocksconsole.NewContext(s.T()) + ctx.EXPECT().TwoColumnDetail("modules/.gitkeep", statusExists).Once() + ctx.EXPECT().TwoColumnDetail("config/modular.go", statusExists).Once() + ctx.EXPECT().TwoColumnDetail("bootstrap/modules.go", statusExists).Once() + ctx.EXPECT().TwoColumnDetail("bootstrap/app.go WithModules(Modules)", statusManual).Once() + ctx.EXPECT().Warning(warning).Once() + + s.NoError(NewInitCommand(nil).Handle(ctx)) +} + +// removeScaffold deletes what the commands write into the package directory. +func removeScaffold(t *testing.T) { + t.Helper() + + for _, dir := range []string{"app", "bootstrap", "config", "modules", "src", "tests"} { + if err := file.Remove(dir); err != nil { + t.Fatal(err) + } + } +} diff --git a/modular/console/stubs.go b/modular/console/stubs.go new file mode 100644 index 000000000..dd2e1bdae --- /dev/null +++ b/modular/console/stubs.go @@ -0,0 +1,36 @@ +package console + +import "strings" + +type Stubs struct{} + +func (r Stubs) Config(pkg, facadesImport, facadesPackage string) string { + content := `package DummyPackage + +import ( + "github.com/goravel/framework/contracts/http" + + "DummyFacadesImport" +) + +func init() { + config := DummyFacadesPackage.Config() + config.Add("modular", map[string]any{ + // Directory of the application's own modules, relative to the project root. + "path": "modules", + + // Route groups wrapping what modules register with Routes() and ApiRoutes(). + "routes": map[string]any{ + "web": map[string]any{"prefix": "", "middleware": []http.Middleware{}}, + "api": map[string]any{"prefix": "api", "middleware": []http.Middleware{}}, + }, + }) +} +` + + return strings.NewReplacer( + "DummyPackage", pkg, + "DummyFacadesImport", facadesImport, + "DummyFacadesPackage", facadesPackage, + ).Replace(content) +} From 9d86c10bc9d7683af2d8c5b51a1e392dfe72a99b Mon Sep 17 00:00:00 2001 From: Saad Date: Wed, 11 Nov 2026 12:12:33 +0600 Subject: [PATCH 11/29] feat(modular): add make:module artisan make:module ... [--api] [--plain] [--order=N] [--force] scaffolds / and lists the module in bootstrap/modules.go. - default: module.go (provider, web and API routes, config, embedded views and translations, Vite entries), providers/, routes/web.go and api.go, http/controllers/, resources/views/index.tmpl, lang/en/messages.json, database/migrations, database/seeders, models, and tests/feature when the app has tests/test_case.go - --api: no views, web routes or Vite entries; translations are kept - --plain: module.go with Name() only - --order=N adds Order() - --force rewrites the stubs and removes the ones the chosen variant does not write (and the directories they leave empty); other files are kept and the module is listed once "BlogPosts" becomes the module "blog-posts" in package blogposts, imported with an alias. Routes are / and /api/, so two modules never claim the same path. The view has no {{ define }}, since define names are global. The controller reads its title through the Lang facade when the app has one, otherwise it uses a plain string. Names Go cannot import (keywords, main, init, internal, vendor) are refused with ModularModuleNameForbidden. A name whose package is already imported in bootstrap/modules.go from another path ("Blogposts" after "BlogPosts", "modular") is refused with ModularModuleImportConflict before anything is written. When WithModules is not in bootstrap/app.go after the run (the chain is assigned to a variable), the command warns with the line to add. modify.AddModule now writes to an existing bootstrap/modules.go even when WithModules is not wired yet, so make:module works right after a modular:init that reported MANUAL. --- errors/list.go | 6 + foundation/application.go | 1 + modular/console/module_make_command.go | 284 ++++++++++++++ modular/console/module_make_command_test.go | 399 ++++++++++++++++++++ modular/console/stubs.go | 274 +++++++++++++- packages/modify/utils.go | 8 +- 6 files changed, 970 insertions(+), 2 deletions(-) create mode 100644 modular/console/module_make_command.go create mode 100644 modular/console/module_make_command_test.go diff --git a/errors/list.go b/errors/list.go index 6778a5b68..04a56fff3 100644 --- a/errors/list.go +++ b/errors/list.go @@ -218,8 +218,14 @@ var ( MigrationRollbackFailed = New("migration rollback failed: %v") ModularModuleDuplicate = New("module %q is registered more than once").SetModule(ModuleModular) + ModularModuleExists = New("module %s already exists at %s, use --force to overwrite it").SetModule(ModuleModular) + ModularModuleImportConflict = New("module %s: package name %q is already used by the import %q in bootstrap/modules.go, choose another name").SetModule(ModuleModular) + ModularModuleNameEmpty = New("the module name cannot be empty").SetModule(ModuleModular) + ModularModuleNameForbidden = New("module name %q is reserved, Go cannot import a package named %q").SetModule(ModuleModular) + ModularModuleNameInvalid = New("module name %q is invalid, use letters, digits and dashes, starting with a letter").SetModule(ModuleModular) ModularModulePathInvalid = New("module %s: directory %q must be a relative path inside the project").SetModule(ModuleModular) ModularModulePathUnknown = New("module %s: cannot derive its directory from package %q, implement Path() to set it").SetModule(ModuleModular) + ModularOptionsConflict = New("the --%s and --%s options cannot be used together").SetModule(ModuleModular) ModularRequiresBootstrapSetup = New("%s needs bootstrap/app.go to build the application with foundation.Setup()").SetModule(ModuleModular) ModularStaticDirInvalid = New("module %s: static directory %q must be a relative path inside the module").SetModule(ModuleModular) ModularStaticDirUnreadable = New("module %s: static directory %q is not a readable directory: %v").SetModule(ModuleModular) diff --git a/foundation/application.go b/foundation/application.go index 65b2e0a48..5c2403cba 100644 --- a/foundation/application.go +++ b/foundation/application.go @@ -1035,6 +1035,7 @@ func (r *Application) defaultCommands() []contractsconsole.Command { console.NewPackageUninstallCommand(binding.Bindings, r.MakeProcess(), r.Json()), console.NewVendorPublishCommand(r.publishes, r.publishGroups), modularconsole.NewInitCommand(r.MakeConfig()), + modularconsole.NewModuleMakeCommand(r.MakeConfig()), } if r.MakeRoute() != nil { diff --git a/modular/console/module_make_command.go b/modular/console/module_make_command.go new file mode 100644 index 000000000..c512d800c --- /dev/null +++ b/modular/console/module_make_command.go @@ -0,0 +1,284 @@ +package console + +import ( + "go/parser" + "go/token" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/goravel/framework/contracts/config" + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" + "github.com/goravel/framework/errors" + "github.com/goravel/framework/packages" + "github.com/goravel/framework/packages/modify" + "github.com/goravel/framework/support" + supportconsole "github.com/goravel/framework/support/console" + "github.com/goravel/framework/support/env" + "github.com/goravel/framework/support/file" + "github.com/goravel/framework/support/path" + "github.com/goravel/framework/support/str" +) + +var ( + // moduleInputPattern is what make:module accepts, moduleNamePattern the kebab name it derives. + moduleInputPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9 _-]*$`) + moduleNamePattern = regexp.MustCompile(`^[a-z][a-z0-9]*(-[a-z0-9]+)*$`) + + // reservedModulePackages are packages Go refuses to import. + reservedModulePackages = map[string]bool{"main": true, "init": true, "internal": true, "vendor": true} +) + +type ModuleMakeCommand struct { + config config.Config +} + +func NewModuleMakeCommand(config config.Config) *ModuleMakeCommand { + return &ModuleMakeCommand{config: config} +} + +// Signature The name and signature of the console command. +func (r *ModuleMakeCommand) Signature() string { + return "make:module" +} + +// Description The console command description. +func (r *ModuleMakeCommand) Description() string { + return "Create a new module" +} + +// Extend The console command extend. +func (r *ModuleMakeCommand) Extend() command.Extend { + return command.Extend{ + ArgsUsage: " ...", + Category: "make", + Flags: []command.Flag{ + &command.BoolFlag{ + Name: "api", + Usage: "Create an API module, without views and web routes", + }, + &command.BoolFlag{ + Name: "plain", + Usage: "Create only module.go", + }, + &command.IntFlag{ + Name: "order", + Usage: "The module order: lower first, negative last", + }, + &command.BoolFlag{ + Name: "force", + Aliases: []string{"f"}, + Usage: "Create the module even if it already exists", + }, + }, + } +} + +// Handle Execute the console command. +func (r *ModuleMakeCommand) Handle(ctx console.Context) error { + supportconsole.HandleEach(ctx, r.makeOne) + + return nil +} + +func (r *ModuleMakeCommand) makeOne(ctx console.Context, name string) error { + if !env.IsBootstrapSetup() { + return errors.ModularRequiresBootstrapSetup.Args(r.Signature()) + } + if ctx.OptionBool("api") && ctx.OptionBool("plain") { + return errors.ModularOptionsConflict.Args("api", "plain") + } + + if name == "" { + var err error + name, err = ctx.Ask("Enter the module name", console.AskOption{ + Validate: func(s string) error { + if s == "" { + return errors.ModularModuleNameEmpty + } + + return nil + }, + }) + if err != nil { + return err + } + } + + stubs, err := newModuleStubs(name, modulesDirectory(r.config)) + if err != nil { + return err + } + stubs.order = ctx.OptionInt("order") + + root := path.Base(filepath.FromSlash(stubs.dir)) + if file.Exists(root) && !ctx.OptionBool("force") { + return errors.ModularModuleExists.Args(stubs.kebab, stubs.dir) + } + + registered, err := stubs.registered() + if err != nil { + return err + } + + files := stubs.files(ctx.OptionBool("api"), ctx.OptionBool("plain")) + if err := removeStaleStubs(root, stubs.files(false, false), files); err != nil { + return err + } + for name, content := range files { + if err := file.PutContent(filepath.Join(root, filepath.FromSlash(name)), content); err != nil { + return err + } + } + + if !registered { + if err := modify.AddModule(stubs.importSpec(), "&"+stubs.pkg+".Module{}"); err != nil { + return err + } + } + + ctx.Success("Module created successfully: " + stubs.dir) + + // An assigned chain (builder := foundation.Setup()...) cannot be edited: the user wires it. + if !file.Contain(path.Bootstrap("app.go"), "WithModules") { + ctx.Warning("Add .WithModules(Modules) to foundation.Setup() in " + filepath.ToSlash(filepath.Join(support.Config.Paths.Bootstrap, "app.go"))) + } + + return nil +} + +// removeStaleStubs deletes the stubs of an earlier --force run that this one no longer writes. +func removeStaleStubs(root string, all, current map[string]string) error { + for name := range all { + if _, ok := current[name]; ok { + continue + } + + stale := filepath.Join(root, filepath.FromSlash(name)) + if err := os.Remove(stale); err != nil { + if os.IsNotExist(err) { + continue + } + + return err + } + // Remove the directories the stub leaves empty; os.Remove refuses the others. + for dir := filepath.Dir(stale); dir != root && os.Remove(dir) == nil; dir = filepath.Dir(dir) { + } + } + + return nil +} + +// moduleStubs holds the names derived from a module name, e.g. "BlogPosts". +type moduleStubs struct { + kebab string // blog-posts: module name and directory + pkg string // blogposts: Go package + studly string // BlogPosts + snake string // blog_posts + title string // Blog Posts + dir string // modules/blog-posts + module string //
/modules/blog-posts + order int +} + +func newModuleStubs(name, directory string) (*moduleStubs, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, errors.ModularModuleNameEmpty + } + + kebab := str.Of(name).Kebab().String() + pkg := strings.ReplaceAll(kebab, "-", "") + if !moduleInputPattern.MatchString(name) || !moduleNamePattern.MatchString(kebab) { + return nil, errors.ModularModuleNameInvalid.Args(name) + } + if token.IsKeyword(pkg) || reservedModulePackages[pkg] { + return nil, errors.ModularModuleNameForbidden.Args(name, pkg) + } + + dir := filepath.ToSlash(filepath.Join(directory, kebab)) + + return &moduleStubs{ + kebab: kebab, + pkg: pkg, + studly: str.Of(kebab).Studly().String(), + snake: strings.ReplaceAll(kebab, "-", "_"), + title: str.Of(kebab).Headline().String(), + dir: dir, + module: packages.Paths().Main().Import() + "/" + dir, + }, nil +} + +// importSpec is the import of the module package, aliased when the directory is not the package name. +func (r *moduleStubs) importSpec() string { + if r.pkg == r.kebab { + return r.module + } + + return r.pkg + " " + r.module +} + +// registered reports whether bootstrap/modules.go lists the module, and fails +// when another import there already uses the module's package name. +func (r *moduleStubs) registered() (bool, error) { + modulesFile := path.Bootstrap("modules.go") + source := modify.ModulesStub() + if file.Exists(modulesFile) { + content, err := file.GetContent(modulesFile) + if err != nil { + return false, err + } + source = content + } + + parsed, err := parser.ParseFile(token.NewFileSet(), modulesFile, source, parser.ImportsOnly) + if err != nil { + return false, err + } + + imported := false + for _, spec := range parsed.Imports { + importPath := strings.Trim(spec.Path.Value, "`\"") + name := importPath[strings.LastIndex(importPath, "/")+1:] + if spec.Name != nil { + name = spec.Name.Name + } + if importPath == r.module { + imported = true + } else if name == r.pkg { + return false, errors.ModularModuleImportConflict.Args(r.kebab, r.pkg, importPath) + } + } + + return imported && strings.Contains(source, "&"+r.pkg+".Module{}"), nil +} + +// files returns the module files by slash path relative to the module directory. +func (r *moduleStubs) files(api, plain bool) map[string]string { + if plain { + return map[string]string{"module.go": r.Module(false, false, false)} + } + + files := map[string]string{ + "module.go": r.Module(!api, true, true), + "providers/service_provider.go": r.ServiceProvider(), + "routes/api.go": r.ApiRoutes(), + "http/controllers/" + r.snake + "_controller.go": r.Controller(!api), + "database/migrations/.gitkeep": "", + "database/seeders/.gitkeep": "", + "models/.gitkeep": "", + "lang/en/messages.json": r.Lang(), + } + if !api { + files["routes/web.go"] = r.WebRoutes() + files["resources/views/index.tmpl"] = r.View() + } + if file.Exists(path.Test("test_case.go")) { + files["tests/feature/"+r.snake+"_test.go"] = r.Test() + } + + return files +} diff --git a/modular/console/module_make_command_test.go b/modular/console/module_make_command_test.go new file mode 100644 index 000000000..7dadcc33e --- /dev/null +++ b/modular/console/module_make_command_test.go @@ -0,0 +1,399 @@ +package console + +import ( + "go/format" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + + "github.com/goravel/framework/errors" + mocksconfig "github.com/goravel/framework/mocks/config" + mocksconsole "github.com/goravel/framework/mocks/console" + "github.com/goravel/framework/packages/modify" + "github.com/goravel/framework/support/file" +) + +type ModuleMakeCommandTestSuite struct { + suite.Suite +} + +func TestModuleMakeCommandTestSuite(t *testing.T) { + suite.Run(t, new(ModuleMakeCommandTestSuite)) +} + +func (s *ModuleMakeCommandTestSuite) SetupTest() { + s.Require().NoError(file.PutContent("bootstrap/app.go", chainedApp)) +} + +func (s *ModuleMakeCommandTestSuite) TearDownTest() { + removeScaffold(s.T()) +} + +// context mocks one make:module call. +func (s *ModuleMakeCommandTestSuite) context(name string, api, plain, force bool, order int) *mocksconsole.Context { + ctx := mocksconsole.NewContext(s.T()) + ctx.EXPECT().Arguments().Return([]string{name}).Once() + ctx.EXPECT().OptionBool("api").Return(api).Maybe() + ctx.EXPECT().OptionBool("plain").Return(plain).Maybe() + ctx.EXPECT().OptionBool("force").Return(force).Maybe() + ctx.EXPECT().OptionInt("order").Return(order).Maybe() + + return ctx +} + +// files lists the module's files and checks every Go file is gofmt-formatted. +func (s *ModuleMakeCommandTestSuite) files(root string) []string { + var files []string + s.Require().NoError(filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + + rel, err := filepath.Rel(root, path) + s.Require().NoError(err) + files = append(files, filepath.ToSlash(rel)) + + if strings.HasSuffix(path, ".go") { + content, err := os.ReadFile(path) + s.Require().NoError(err) + formatted, err := format.Source(content) + s.Require().NoError(err, rel) + s.Equal(string(formatted), string(content), rel) + } + + return nil + })) + + return files +} + +func (s *ModuleMakeCommandTestSuite) TestSignature() { + cmd := NewModuleMakeCommand(nil) + + s.Equal("make:module", cmd.Signature()) + s.Equal("Create a new module", cmd.Description()) + s.Equal("make", cmd.Extend().Category) + s.Len(cmd.Extend().Flags, 4) +} + +func (s *ModuleMakeCommandTestSuite) TestDefault() { + s.Require().NoError(file.PutContent("tests/test_case.go", "package tests\n")) + + ctx := s.context("Blog", false, false, false, 0) + ctx.EXPECT().Success("Module created successfully: modules/blog").Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + + s.ElementsMatch([]string{ + "module.go", + "providers/service_provider.go", + "routes/web.go", + "routes/api.go", + "http/controllers/blog_controller.go", + "resources/views/index.tmpl", + "lang/en/messages.json", + "database/migrations/.gitkeep", + "database/seeders/.gitkeep", + "models/.gitkeep", + "tests/feature/blog_test.go", + }, s.files("modules/blog")) + + module, err := file.GetContent("modules/blog/module.go") + s.NoError(err) + s.Contains(module, "package blog\n") + s.Contains(module, "//go:embed resources/views\nvar views embed.FS") + s.Contains(module, "//go:embed lang\nvar lang embed.FS") + s.Contains(module, `"github.com/goravel/framework/modules/blog/providers"`) + s.Contains(module, "return \"blog\"\n") + for _, method := range []string{"ServiceProviders()", "Routes(router route.Router)", "ApiRoutes(router route.Router)", "Config()", "Views()", "Translations()", "ViteEntries()"} { + s.Contains(module, "func (m *Module) "+method) + } + s.NotContains(module, "Order()") + + // No app/facades here, so the controller uses the framework facades. + controller, err := file.GetContent("modules/blog/http/controllers/blog_controller.go") + s.NoError(err) + s.Contains(controller, `"github.com/goravel/framework/facades"`) + s.Contains(controller, `Make("blog/index.tmpl"`) + s.Contains(controller, `facades.Lang(ctx).Get("blog/messages.title")`) + + s.True(file.Contain("modules/blog/routes/web.go", `router.Get("/blog", controllers.NewBlogController().Index)`)) + s.True(file.Contain("modules/blog/routes/api.go", `router.Get("/blog", controllers.NewBlogController().Show)`)) + s.True(file.Contain("modules/blog/lang/en/messages.json", `"title": "Blog"`)) + s.False(file.Contain("modules/blog/resources/views/index.tmpl", "define")) + s.True(file.Contain("modules/blog/tests/feature/blog_test.go", `"github.com/goravel/framework/tests"`)) + s.True(file.Contain("modules/blog/tests/feature/blog_test.go", "tests.TestCase")) + + s.True(file.Contain("bootstrap/app.go", "WithModules(Modules)")) + s.True(file.Contain("bootstrap/modules.go", `"github.com/goravel/framework/modules/blog"`)) + s.True(file.Contain("bootstrap/modules.go", "&blog.Module{},")) +} + +func (s *ModuleMakeCommandTestSuite) TestApi() { + ctx := s.context("billing", true, false, false, 0) + ctx.EXPECT().Success("Module created successfully: modules/billing").Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + + s.ElementsMatch([]string{ + "module.go", + "providers/service_provider.go", + "routes/api.go", + "http/controllers/billing_controller.go", + "lang/en/messages.json", + "database/migrations/.gitkeep", + "database/seeders/.gitkeep", + "models/.gitkeep", + }, s.files("modules/billing")) + + module, err := file.GetContent("modules/billing/module.go") + s.NoError(err) + s.Contains(module, "func (m *Module) ApiRoutes(router route.Router)") + s.Contains(module, "//go:embed lang\nvar lang embed.FS") + s.Contains(module, "func (m *Module) Translations() fs.FS") + s.True(file.Contain("modules/billing/lang/en/messages.json", `"title": "Billing"`)) + for _, absent := range []string{"resources/views", "Routes(router route.Router) {\n\troutes.Web", "Views()", "ViteEntries()"} { + s.NotContains(module, absent) + } + + controller, err := file.GetContent("modules/billing/http/controllers/billing_controller.go") + s.NoError(err) + s.NotContains(controller, "Index") + s.NotContains(controller, "facades") + s.Contains(controller, `"module": "billing"`) +} + +func (s *ModuleMakeCommandTestSuite) TestPlainWithOrder() { + ctx := s.context("Blog", false, true, false, 5) + ctx.EXPECT().Success("Module created successfully: modules/blog").Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + + s.Equal([]string{"module.go"}, s.files("modules/blog")) + module, err := file.GetContent("modules/blog/module.go") + s.NoError(err) + s.Equal(`package blog + +type Module struct{} + +func (m *Module) Name() string { + return "blog" +} + +func (m *Module) Order() int { + return 5 +} +`, module) + s.True(file.Contain("bootstrap/modules.go", "&blog.Module{},")) +} + +func (s *ModuleMakeCommandTestSuite) TestKebabNameAndConfiguredPath() { + config := mocksconfig.NewConfig(s.T()) + config.EXPECT().GetString("modular.path", "modules").Return("src/modules").Once() + + ctx := s.context("BlogPosts", false, false, false, 0) + ctx.EXPECT().Success("Module created successfully: src/modules/blog-posts").Once() + + s.NoError(NewModuleMakeCommand(config).Handle(ctx)) + + s.Contains(s.files("src/modules/blog-posts"), "http/controllers/blog_posts_controller.go") + module, err := file.GetContent("src/modules/blog-posts/module.go") + s.NoError(err) + s.Contains(module, "package blogposts\n") + s.Contains(module, "return \"blog-posts\"\n") + s.True(file.Contain("src/modules/blog-posts/http/controllers/blog_posts_controller.go", "type BlogPostsController struct{}")) + s.True(file.Contain("src/modules/blog-posts/lang/en/messages.json", `"title": "Blog Posts"`)) + s.True(file.Contain("bootstrap/modules.go", `blogposts "github.com/goravel/framework/src/modules/blog-posts"`)) + s.True(file.Contain("bootstrap/modules.go", "&blogposts.Module{},")) +} + +func (s *ModuleMakeCommandTestSuite) TestAppFacadesWithoutLang() { + s.Require().NoError(file.PutContent("app/facades/config.go", "package facades\n")) + + ctx := s.context("Blog", false, false, false, 0) + ctx.EXPECT().Success("Module created successfully: modules/blog").Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + + controller, err := file.GetContent("modules/blog/http/controllers/blog_controller.go") + s.NoError(err) + s.NotContains(controller, "facades") + s.Contains(controller, `"title": "Blog",`) + s.files("modules/blog") + + // With the app's Lang facade, the controller uses it. + s.Require().NoError(file.PutContent("app/facades/lang.go", "package facades\n")) + ctx = s.context("Blog", false, false, true, 0) + ctx.EXPECT().Success("Module created successfully: modules/blog").Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + + s.True(file.Contain("modules/blog/http/controllers/blog_controller.go", `"github.com/goravel/framework/app/facades"`)) + s.files("modules/blog") +} + +func (s *ModuleMakeCommandTestSuite) TestExistingModulesFileWithoutWithModules() { + s.Require().NoError(file.PutContent("bootstrap/app.go", assignedApp)) + s.Require().NoError(file.PutContent("bootstrap/modules.go", modify.ModulesStub())) + + warning := "Add .WithModules(Modules) to foundation.Setup() in bootstrap/app.go" + ctx := s.context("Blog", false, true, false, 0) + ctx.EXPECT().Success("Module created successfully: modules/blog").Once() + ctx.EXPECT().Warning(warning).Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + + s.True(file.Contain("bootstrap/modules.go", "&blog.Module{},")) + app, err := file.GetContent("bootstrap/app.go") + s.NoError(err) + s.Equal(assignedApp, app) +} + +// An assigned chain cannot be edited: modules.go is written and the user is told to wire it. +func (s *ModuleMakeCommandTestSuite) TestAssignedChainWithoutModulesFile() { + s.Require().NoError(file.PutContent("bootstrap/app.go", assignedApp)) + + ctx := s.context("Blog", false, true, false, 0) + ctx.EXPECT().Success("Module created successfully: modules/blog").Once() + ctx.EXPECT().Warning("Add .WithModules(Modules) to foundation.Setup() in bootstrap/app.go").Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + + s.True(file.Contain("bootstrap/modules.go", "&blog.Module{},")) + app, err := file.GetContent("bootstrap/app.go") + s.NoError(err) + s.Equal(assignedApp, app) +} + +// --force removes the stubs the new variant does not write, keeps other files and registers once. +func (s *ModuleMakeCommandTestSuite) TestForceRemovesStaleStubs() { + ctx := s.context("Blog", false, false, false, 0) + ctx.EXPECT().Success("Module created successfully: modules/blog").Once() + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + s.Require().NoError(file.PutContent("modules/blog/resources/js/app.js", "")) + s.Require().NoError(file.PutContent("modules/blog/models/post.go", "package models\n")) + + ctx = s.context("Blog", true, false, true, 0) + ctx.EXPECT().Success("Module created successfully: modules/blog").Once() + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + + s.ElementsMatch([]string{ + "module.go", + "providers/service_provider.go", + "routes/api.go", + "http/controllers/blog_controller.go", + "lang/en/messages.json", + "database/migrations/.gitkeep", + "database/seeders/.gitkeep", + "models/.gitkeep", + "models/post.go", + "resources/js/app.js", + }, s.files("modules/blog")) + s.False(file.Exists("modules/blog/resources/views")) + + ctx = s.context("Blog", false, true, true, 0) + ctx.EXPECT().Success("Module created successfully: modules/blog").Once() + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + + s.ElementsMatch([]string{"module.go", "models/post.go", "resources/js/app.js"}, s.files("modules/blog")) + + modules, err := file.GetContent("bootstrap/modules.go") + s.NoError(err) + s.Equal(1, strings.Count(modules, "&blog.Module{},")) +} + +func (s *ModuleMakeCommandTestSuite) TestErrors() { + s.Run("exists without force", func() { + s.Require().NoError(file.PutContent("modules/blog/module.go", "package blog\n")) + + ctx := s.context("Blog", false, false, false, 0) + ctx.EXPECT().Error(errors.ModularModuleExists.Args("blog", "modules/blog").Error()).Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + s.Equal([]string{"module.go"}, s.files("modules/blog")) + s.False(file.Exists("bootstrap/modules.go")) + }) + + s.Run("api and plain", func() { + ctx := s.context("Shop", true, true, false, 0) + ctx.EXPECT().Error(errors.ModularOptionsConflict.Args("api", "plain").Error()).Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + s.False(file.Exists("modules/shop")) + }) + + for name, pkg := range map[string]string{"type": "type", "Go": "go", "main": "main", "Init": "init", "internal": "internal", "vendor": "vendor"} { + s.Run("reserved name "+name, func() { + ctx := s.context(name, false, false, false, 0) + ctx.EXPECT().Error(errors.ModularModuleNameForbidden.Args(name, pkg).Error()).Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + s.False(file.Exists("modules/" + pkg)) + }) + } + + s.Run("package name of the contracts import", func() { + ctx := s.context("Modular", false, false, false, 0) + ctx.EXPECT().Error(errors.ModularModuleImportConflict.Args("modular", "modular", "github.com/goravel/framework/contracts/modular").Error()).Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + s.False(file.Exists("modules/modular")) + s.False(file.Exists("bootstrap/modules.go")) + }) + + s.Run("package name of another module", func() { + ctx := s.context("BlogPosts", false, true, false, 0) + ctx.EXPECT().Success("Module created successfully: modules/blog-posts").Once() + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + + ctx = s.context("Blogposts", false, true, false, 0) + ctx.EXPECT().Error(errors.ModularModuleImportConflict.Args("blogposts", "blogposts", "github.com/goravel/framework/modules/blog-posts").Error()).Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + s.False(file.Exists("modules/blogposts")) + s.Equal(1, strings.Count(mustContent(s.T(), "bootstrap/modules.go"), "blogposts"+" ")) + removeScaffold(s.T()) + s.Require().NoError(file.PutContent("bootstrap/app.go", chainedApp)) + }) + + for _, name := range []string{"1blog", "blog/posts", "blog.posts", "-"} { + s.Run("invalid name "+name, func() { + ctx := s.context(name, false, false, false, 0) + ctx.EXPECT().Error(errors.ModularModuleNameInvalid.Args(name).Error()).Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + }) + } + + s.Run("empty name", func() { + ctx := s.context("", false, false, false, 0) + ctx.EXPECT().Ask("Enter the module name", mock.Anything).Return("", errors.ModularModuleNameEmpty).Once() + ctx.EXPECT().Error(errors.ModularModuleNameEmpty.Error()).Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + }) + + s.Run("legacy bootstrap", func() { + s.Require().NoError(file.PutContent("bootstrap/app.go", "package bootstrap\n")) + + ctx := s.context("Shop", false, false, false, 0) + ctx.EXPECT().Error(errors.ModularRequiresBootstrapSetup.Args("make:module").Error()).Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + s.False(file.Exists("modules/shop")) + }) +} + +func mustContent(t *testing.T, path string) string { + content, err := file.GetContent(path) + if err != nil { + t.Fatal(err) + } + + return content +} diff --git a/modular/console/stubs.go b/modular/console/stubs.go index dd2e1bdae..a208fad49 100644 --- a/modular/console/stubs.go +++ b/modular/console/stubs.go @@ -1,6 +1,13 @@ package console -import "strings" +import ( + "fmt" + "strings" + + "github.com/goravel/framework/packages" + "github.com/goravel/framework/support/file" + "github.com/goravel/framework/support/path" +) type Stubs struct{} @@ -34,3 +41,268 @@ func init() { "DummyFacadesPackage", facadesPackage, ).Replace(content) } + +// langFacade returns the facades package exposing Lang(ctx): the app's, the +// framework's when the app has no facades, or ok=false when the app's lacks it. +func langFacade() (importPath, pkg string, ok bool) { + if file.Exists(path.Facade("lang.go")) { + facades := packages.Paths().Facades() + + return facades.Import(), facades.Package(), true + } + if file.Exists(path.Facade()) { + return "", "", false + } + + return "github.com/goravel/framework/facades", "facades", true +} + +func (r *moduleStubs) replace(content string) string { + return strings.NewReplacer( + "DummyModule", r.module, + "DummyPackage", r.pkg, + "DummyStudly", r.studly, + "DummySnake", r.snake, + "DummyTitle", r.title, + "DummyName", r.kebab, + ).Replace(content) +} + +// Module is module.go: web adds views, web routes and Vite entries, lang the +// translations, full the provider, API routes and config. +func (r *moduleStubs) Module(web, lang, full bool) string { + var imports, vars, methods []string + + if web || lang { + imports = append(imports, "\t\"embed\"\n\t\"io/fs\"\n") + } + if web { + vars = append(vars, "//go:embed resources/views\nvar views embed.FS\n\n") + } + if lang { + vars = append(vars, "//go:embed lang\nvar lang embed.FS\n\n") + } + if full { + imports = append(imports, ` "github.com/goravel/framework/contracts/foundation" + "github.com/goravel/framework/contracts/route" +`, ` "DummyModule/providers" + "DummyModule/routes" +`) + methods = append(methods, ` +func (m *Module) ServiceProviders() []foundation.ServiceProvider { + return []foundation.ServiceProvider{ + &providers.ServiceProvider{}, + } +} +`) + } + if web { + methods = append(methods, ` +func (m *Module) Routes(router route.Router) { + routes.Web(router) +} +`) + } + if full { + methods = append(methods, ` +func (m *Module) ApiRoutes(router route.Router) { + routes.Api(router) +} + +// Config is available as "modules.DummyName". +func (m *Module) Config() map[string]any { + return map[string]any{ + "enabled": true, + } +} +`) + } + if web { + methods = append(methods, ` +// Views are named "DummyName/", e.g. "DummyName/index.tmpl". +func (m *Module) Views() fs.FS { + sub, _ := fs.Sub(views, "resources/views") + + return sub +} +`) + } + if lang { + methods = append(methods, ` +// Translations are grouped as "DummyName/", e.g. "DummyName/messages.title". +func (m *Module) Translations() fs.FS { + sub, _ := fs.Sub(lang, "lang") + + return sub +} +`) + } + if web { + methods = append(methods, ` +// ViteEntries are relative to the module directory, e.g. "resources/js/app.js". +// ViteAliases() and Static() are optional too, see contracts/modular. +func (m *Module) ViteEntries() []string { + return []string{} +} +`) + } + if r.order != 0 { + methods = append(methods, fmt.Sprintf(` +func (m *Module) Order() int { + return %d +} +`, r.order)) + } + + content := "package DummyPackage\n\n" + if len(imports) > 0 { + content += "import (\n" + strings.Join(imports, "\n") + ")\n\n" + } + content += strings.Join(vars, "") + `type Module struct{} + +func (m *Module) Name() string { + return "DummyName" +} +` + strings.Join(methods, "") + + return r.replace(content) +} + +func (r *moduleStubs) ServiceProvider() string { + return `package providers + +import ( + "github.com/goravel/framework/contracts/foundation" +) + +type ServiceProvider struct{} + +// Register service bindings here. +func (r *ServiceProvider) Register(app foundation.Application) { +} + +// Boot runs after all service providers have been registered. +func (r *ServiceProvider) Boot(app foundation.Application) { +} +` +} + +func (r *moduleStubs) WebRoutes() string { + return r.replace(`package routes + +import ( + "github.com/goravel/framework/contracts/route" + + "DummyModule/http/controllers" +) + +func Web(router route.Router) { + router.Get("/DummyName", controllers.NewDummyStudlyController().Index) +} +`) +} + +func (r *moduleStubs) ApiRoutes() string { + return r.replace(`package routes + +import ( + "github.com/goravel/framework/contracts/route" + + "DummyModule/http/controllers" +) + +func Api(router route.Router) { + router.Get("/DummyName", controllers.NewDummyStudlyController().Show) +} +`) +} + +// Controller has Show for the API route and, with web, Index rendering the module view. +func (r *moduleStubs) Controller(web bool) string { + imports := "import (\n\t\"github.com/goravel/framework/contracts/http\"\n)\n" + index := "" + if web { + title := `"DummyTitle"` + if facadesImport, facadesPackage, ok := langFacade(); ok { + imports = "import (\n\t\"github.com/goravel/framework/contracts/http\"\n\n\t\"" + facadesImport + "\"\n)\n" + title = facadesPackage + `.Lang(ctx).Get("DummyName/messages.title")` + } + index = ` +func (r *DummyStudlyController) Index(ctx http.Context) http.Response { + return ctx.Response().View().Make("DummyName/index.tmpl", map[string]any{ + "title": ` + title + `, + }) +} +` + } + + return r.replace(`package controllers + +` + imports + ` +type DummyStudlyController struct{} + +func NewDummyStudlyController() *DummyStudlyController { + return &DummyStudlyController{} +} +` + index + ` +func (r *DummyStudlyController) Show(ctx http.Context) http.Response { + return ctx.Response().Success().Json(http.Json{ + "module": "DummyName", + }) +} +`) +} + +// View has no {{ define }}: define names are global and would collide across modules. +func (r *moduleStubs) View() string { + return ` + + + + + {{ .title }} + + +

{{ .title }}

+ + +` +} + +func (r *moduleStubs) Lang() string { + return r.replace(`{ + "title": "DummyTitle" +} +`) +} + +func (r *moduleStubs) Test() string { + tests := packages.Paths().Tests() + + return r.replace(strings.NewReplacer( + "DummyTestsImport", tests.Import(), + "DummyTestsPackage", tests.Package(), + ).Replace(`package feature + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + "DummyTestsImport" +) + +type DummyStudlyTestSuite struct { + suite.Suite + DummyTestsPackage.TestCase +} + +func TestDummyStudlyTestSuite(t *testing.T) { + suite.Run(t, new(DummyStudlyTestSuite)) +} + +func (s *DummyStudlyTestSuite) TestIndex() { + // TODO +} +`)) +} diff --git a/packages/modify/utils.go b/packages/modify/utils.go index 42fe74d49..a2efd7529 100644 --- a/packages/modify/utils.go +++ b/packages/modify/utils.go @@ -96,8 +96,14 @@ func AddMigration(pkg, migration string) error { } // AddModule adds module to bootstrap/modules.go, wiring WithModules(Modules) when missing. +// An existing modules.go is the list, even before WithModules is wired by hand. func AddModule(pkg, module string) error { - return newWithSliceHandler(moduleConfig()).AddItem(pkg, module) + handler := newWithSliceHandler(moduleConfig()) + if handler.fileExists { + return handler.addItemToFile(pkg, module) + } + + return handler.AddItem(pkg, module) } // AddProvider adds service provider to the foundation.Setup() chain in the Boot function. From d210351b2eec00bf6614b5b0345c9a8beb6e18c9 Mon Sep 17 00:00:00 2001 From: Saad Date: Wed, 11 Nov 2026 12:45:24 +0600 Subject: [PATCH 12/29] feat(modular): add ProvidesSources and modular:publish A third-party module (a module from another Go module, without Path()) is placed at /. Vite and Tailwind cannot read its files from the Go module cache, so they are copied into the project. - contracts/modular.ProvidesSources: Sources() fs.FS, the files to copy. Mock generated; the registry reports the "sources" capability. - modular.Publish copies Sources(), without Go files (*.go, go.mod, go.sum: they would be compiled into the application), and, unless the sources already hold resources/views, the module's Views() into resources/views. It writes DO-NOT-EDIT.md and the marker .modular-publish.json (Go module, version, sum, file list), removes files an earlier publish wrote that the module no longer ships, and never touches other files. A directory without the marker, or with the marker of another Go module, is refused (ModularPublishTargetNotOwned). - modular.DependencyOf resolves the Go module and version from the build info (the replacement when replaced); modular.PublishState reports none / missing / published / stale / conflict. It compares the marker and the files on disk, so a replaced or workspace module that changed without a new version, and an edited copy, are stale. conflict is a directory Publish would refuse. - artisan modular:publish [--module=] prints PUBLISHED, UNCHANGED or SKIPPED per module. A failing module prints its error and FAILED, does not stop the others, and the command returns ModularPublishFailed with the count. - modular:init adds "//go:generate go run . artisan modular:publish" to main.go, so go generate publishes after a dependency upgrade. --- contracts/modular/modular.go | 7 + errors/list.go | 4 + foundation/application.go | 1 + mocks/modular/ProvidesSources.go | 83 +++++++ modular/console/console.go | 6 + modular/console/init_command.go | 41 +++- modular/console/init_command_test.go | 54 ++++- modular/console/publish_command.go | 103 +++++++++ modular/console/publish_command_test.go | 149 +++++++++++++ modular/publish.go | 279 ++++++++++++++++++++++++ modular/publish_test.go | 251 +++++++++++++++++++++ modular/registry.go | 1 + modular/registry_test.go | 2 +- 13 files changed, 978 insertions(+), 3 deletions(-) create mode 100644 mocks/modular/ProvidesSources.go create mode 100644 modular/console/publish_command.go create mode 100644 modular/console/publish_command_test.go create mode 100644 modular/publish.go create mode 100644 modular/publish_test.go diff --git a/contracts/modular/modular.go b/contracts/modular/modular.go index 790056a4d..37e110d64 100644 --- a/contracts/modular/modular.go +++ b/contracts/modular/modular.go @@ -107,6 +107,13 @@ type ProvidesViteAlias interface { ViteAliases() map[string]string } +// ProvidesSources ships files of a third-party module that modular:publish copies +// into its directory for Vite and Tailwind, e.g. "resources/js/app.js". Go files +// (*.go, go.mod, go.sum) are skipped. +type ProvidesSources interface { + Sources() fs.FS +} + // ProvidesStatic serves files as-is: URL prefix -> Path. type ProvidesStatic interface { Static() map[string]Path diff --git a/errors/list.go b/errors/list.go index 04a56fff3..3e677f3c1 100644 --- a/errors/list.go +++ b/errors/list.go @@ -223,9 +223,13 @@ var ( ModularModuleNameEmpty = New("the module name cannot be empty").SetModule(ModuleModular) ModularModuleNameForbidden = New("module name %q is reserved, Go cannot import a package named %q").SetModule(ModuleModular) ModularModuleNameInvalid = New("module name %q is invalid, use letters, digits and dashes, starting with a letter").SetModule(ModuleModular) + ModularModuleNotFound = New("module %s is not registered").SetModule(ModuleModular) ModularModulePathInvalid = New("module %s: directory %q must be a relative path inside the project").SetModule(ModuleModular) ModularModulePathUnknown = New("module %s: cannot derive its directory from package %q, implement Path() to set it").SetModule(ModuleModular) ModularOptionsConflict = New("the --%s and --%s options cannot be used together").SetModule(ModuleModular) + ModularPublishFailed = New("modular:publish failed for %d of %d modules").SetModule(ModuleModular) + ModularPublishTargetNotOwned = New("module %s: %s exists and was not published from %s, move it or set the module's Path()").SetModule(ModuleModular) + ModularRegistryNotSet = New("the module registry is not set, build the application with foundation.Setup()").SetModule(ModuleModular) ModularRequiresBootstrapSetup = New("%s needs bootstrap/app.go to build the application with foundation.Setup()").SetModule(ModuleModular) ModularStaticDirInvalid = New("module %s: static directory %q must be a relative path inside the module").SetModule(ModuleModular) ModularStaticDirUnreadable = New("module %s: static directory %q is not a readable directory: %v").SetModule(ModuleModular) diff --git a/foundation/application.go b/foundation/application.go index 5c2403cba..bc2acbacd 100644 --- a/foundation/application.go +++ b/foundation/application.go @@ -1036,6 +1036,7 @@ func (r *Application) defaultCommands() []contractsconsole.Command { console.NewVendorPublishCommand(r.publishes, r.publishGroups), modularconsole.NewInitCommand(r.MakeConfig()), modularconsole.NewModuleMakeCommand(r.MakeConfig()), + modularconsole.NewPublishCommand(r.MakeModular()), } if r.MakeRoute() != nil { diff --git a/mocks/modular/ProvidesSources.go b/mocks/modular/ProvidesSources.go new file mode 100644 index 000000000..81979d092 --- /dev/null +++ b/mocks/modular/ProvidesSources.go @@ -0,0 +1,83 @@ +// Code generated by mockery. DO NOT EDIT. + +package modular + +import ( + fs "io/fs" + + mock "github.com/stretchr/testify/mock" +) + +// ProvidesSources is an autogenerated mock type for the ProvidesSources type +type ProvidesSources struct { + mock.Mock +} + +type ProvidesSources_Expecter struct { + mock *mock.Mock +} + +func (_m *ProvidesSources) EXPECT() *ProvidesSources_Expecter { + return &ProvidesSources_Expecter{mock: &_m.Mock} +} + +// Sources provides a mock function with no fields +func (_m *ProvidesSources) Sources() fs.FS { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Sources") + } + + var r0 fs.FS + if rf, ok := ret.Get(0).(func() fs.FS); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(fs.FS) + } + } + + return r0 +} + +// ProvidesSources_Sources_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Sources' +type ProvidesSources_Sources_Call struct { + *mock.Call +} + +// Sources is a helper method to define mock.On call +func (_e *ProvidesSources_Expecter) Sources() *ProvidesSources_Sources_Call { + return &ProvidesSources_Sources_Call{Call: _e.mock.On("Sources")} +} + +func (_c *ProvidesSources_Sources_Call) Run(run func()) *ProvidesSources_Sources_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ProvidesSources_Sources_Call) Return(_a0 fs.FS) *ProvidesSources_Sources_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProvidesSources_Sources_Call) RunAndReturn(run func() fs.FS) *ProvidesSources_Sources_Call { + _c.Call.Return(run) + return _c +} + +// NewProvidesSources creates a new instance of ProvidesSources. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProvidesSources(t interface { + mock.TestingT + Cleanup(func()) +}) *ProvidesSources { + mock := &ProvidesSources{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/modular/console/console.go b/modular/console/console.go index 40a3eb03c..71ecbc25f 100644 --- a/modular/console/console.go +++ b/modular/console/console.go @@ -14,6 +14,12 @@ const ( statusCreated = "CREATED" statusExists = "EXISTS" statusManual = "MANUAL" + + statusPublished = "PUBLISHED" + statusUnchanged = "UNCHANGED" + statusFailed = "FAILED" + statusSkippedLocal = "SKIPPED (local)" + statusSkippedNone = "SKIPPED (nothing to publish)" ) // modulesDirectory is modular.path, also before config/modular.go exists. diff --git a/modular/console/init_command.go b/modular/console/init_command.go index dbafb63aa..c752da218 100644 --- a/modular/console/init_command.go +++ b/modular/console/init_command.go @@ -2,6 +2,7 @@ package console import ( "path/filepath" + "strings" "github.com/goravel/framework/contracts/config" "github.com/goravel/framework/contracts/console" @@ -57,7 +58,45 @@ func (r *InitCommand) Handle(ctx console.Context) error { return err } - return r.wireModules(ctx) + if err := r.wireModules(ctx); err != nil { + return err + } + + return r.addGenerate(ctx) +} + +// generateDirective makes go generate publish third-party modules. +const generateDirective = "//go:generate go run . artisan modular:publish" + +// addGenerate adds generateDirective to main.go, above func main. +func (r *InitCommand) addGenerate(ctx console.Context) error { + label := "main.go go:generate modular:publish" + mainFile := path.Base("main.go") + + content, err := file.GetContent(mainFile) + if err != nil { + ctx.TwoColumnDetail(label, statusManual) + ctx.Warning("Add " + generateDirective + " to the main package") + + return nil + } + if strings.Contains(content, generateDirective) { + ctx.TwoColumnDetail(label, statusExists) + + return nil + } + + if before, after, found := strings.Cut(content, "\nfunc main("); found { + content = before + "\n" + generateDirective + "\n\nfunc main(" + after + } else { + content = strings.TrimRight(content, "\n") + "\n\n" + generateDirective + "\n" + } + if err := file.PutContent(mainFile, content); err != nil { + return err + } + ctx.TwoColumnDetail(label, statusCreated) + + return nil } // wireModules creates bootstrap/modules.go and adds WithModules(Modules) to foundation.Setup(). diff --git a/modular/console/init_command_test.go b/modular/console/init_command_test.go index 89db620d1..c55530f7d 100644 --- a/modular/console/init_command_test.go +++ b/modular/console/init_command_test.go @@ -1,8 +1,10 @@ package console import ( + "strings" "testing" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" "github.com/goravel/framework/errors" @@ -38,6 +40,18 @@ func Boot() contractsfoundation.Application { } ` +// mainFile has a build tag so the go tool ignores it in this package. +const mainFile = `//go:build ignore + +package main + +import "goravel/bootstrap" + +func main() { + bootstrap.Boot().Start() +} +` + type InitCommandTestSuite struct { suite.Suite } @@ -67,12 +81,14 @@ func (s *InitCommandTestSuite) TestRequiresBootstrapSetup() { func (s *InitCommandTestSuite) TestCreatesThenReportsExisting() { s.Require().NoError(file.PutContent("bootstrap/app.go", chainedApp)) + s.Require().NoError(file.PutContent("main.go", mainFile)) ctx := mocksconsole.NewContext(s.T()) ctx.EXPECT().TwoColumnDetail("modules/.gitkeep", statusCreated).Once() ctx.EXPECT().TwoColumnDetail("config/modular.go", statusCreated).Once() ctx.EXPECT().TwoColumnDetail("bootstrap/modules.go", statusCreated).Once() ctx.EXPECT().TwoColumnDetail("bootstrap/app.go WithModules(Modules)", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("main.go go:generate modular:publish", statusCreated).Once() s.NoError(NewInitCommand(nil).Handle(ctx)) @@ -98,16 +114,48 @@ func (s *InitCommandTestSuite) TestCreatesThenReportsExisting() { ctx.EXPECT().TwoColumnDetail("config/modular.go", statusExists).Once() ctx.EXPECT().TwoColumnDetail("bootstrap/modules.go", statusExists).Once() ctx.EXPECT().TwoColumnDetail("bootstrap/app.go WithModules(Modules)", statusExists).Once() + ctx.EXPECT().TwoColumnDetail("main.go go:generate modular:publish", statusExists).Once() s.NoError(NewInitCommand(nil).Handle(ctx)) again, err := file.GetContent("bootstrap/app.go") s.NoError(err) s.Equal(app, again) + + main, err := file.GetContent("main.go") + s.NoError(err) + s.Equal(strings.Replace(mainFile, "\nfunc main(", "\n"+generateDirective+"\n\nfunc main(", 1), main) +} + +func (s *InitCommandTestSuite) TestGenerateDirective() { + s.Require().NoError(file.PutContent("bootstrap/app.go", chainedApp)) + rows := func(status string) *mocksconsole.Context { + ctx := mocksconsole.NewContext(s.T()) + ctx.EXPECT().TwoColumnDetail(mock.Anything, mock.Anything).Times(4) + ctx.EXPECT().TwoColumnDetail("main.go go:generate modular:publish", status).Once() + + return ctx + } + + // No main.go: left to the user. + ctx := rows(statusManual) + ctx.EXPECT().Warning("Add " + generateDirective + " to the main package").Once() + s.NoError(NewInitCommand(nil).Handle(ctx)) + s.False(file.Exists("main.go")) + + // No func main in main.go: appended. + s.Require().NoError(file.PutContent("main.go", "//go:build ignore\n\npackage main\n")) + s.NoError(NewInitCommand(nil).Handle(rows(statusCreated))) + main, err := file.GetContent("main.go") + s.NoError(err) + s.Equal("//go:build ignore\n\npackage main\n\n"+generateDirective+"\n", main) + + s.NoError(NewInitCommand(nil).Handle(rows(statusExists))) } func (s *InitCommandTestSuite) TestAppFacadesAndConfiguredPath() { s.Require().NoError(file.PutContent("bootstrap/app.go", chainedApp)) + s.Require().NoError(file.PutContent("main.go", mainFile)) s.Require().NoError(file.PutContent("app/facades/config.go", "package facades\n")) config := mocksconfig.NewConfig(s.T()) @@ -118,6 +166,7 @@ func (s *InitCommandTestSuite) TestAppFacadesAndConfiguredPath() { ctx.EXPECT().TwoColumnDetail("config/modular.go", statusCreated).Once() ctx.EXPECT().TwoColumnDetail("bootstrap/modules.go", statusCreated).Once() ctx.EXPECT().TwoColumnDetail("bootstrap/app.go WithModules(Modules)", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("main.go go:generate modular:publish", statusCreated).Once() s.NoError(NewInitCommand(config).Handle(ctx)) @@ -127,6 +176,7 @@ func (s *InitCommandTestSuite) TestAppFacadesAndConfiguredPath() { func (s *InitCommandTestSuite) TestAssignedChainIsManual() { s.Require().NoError(file.PutContent("bootstrap/app.go", assignedApp)) + s.Require().NoError(file.PutContent("main.go", mainFile)) warning := "Add .WithModules(Modules) to foundation.Setup() in bootstrap/app.go" ctx := mocksconsole.NewContext(s.T()) @@ -134,6 +184,7 @@ func (s *InitCommandTestSuite) TestAssignedChainIsManual() { ctx.EXPECT().TwoColumnDetail("config/modular.go", statusCreated).Once() ctx.EXPECT().TwoColumnDetail("bootstrap/modules.go", statusCreated).Once() ctx.EXPECT().TwoColumnDetail("bootstrap/app.go WithModules(Modules)", statusManual).Once() + ctx.EXPECT().TwoColumnDetail("main.go go:generate modular:publish", statusCreated).Once() ctx.EXPECT().Warning(warning).Once() s.NoError(NewInitCommand(nil).Handle(ctx)) @@ -149,6 +200,7 @@ func (s *InitCommandTestSuite) TestAssignedChainIsManual() { ctx.EXPECT().TwoColumnDetail("config/modular.go", statusExists).Once() ctx.EXPECT().TwoColumnDetail("bootstrap/modules.go", statusExists).Once() ctx.EXPECT().TwoColumnDetail("bootstrap/app.go WithModules(Modules)", statusManual).Once() + ctx.EXPECT().TwoColumnDetail("main.go go:generate modular:publish", statusExists).Once() ctx.EXPECT().Warning(warning).Once() s.NoError(NewInitCommand(nil).Handle(ctx)) @@ -158,7 +210,7 @@ func (s *InitCommandTestSuite) TestAssignedChainIsManual() { func removeScaffold(t *testing.T) { t.Helper() - for _, dir := range []string{"app", "bootstrap", "config", "modules", "src", "tests"} { + for _, dir := range []string{"app", "bootstrap", "config", "modules", "src", "tests", "main.go"} { if err := file.Remove(dir); err != nil { t.Fatal(err) } diff --git a/modular/console/publish_command.go b/modular/console/publish_command.go new file mode 100644 index 000000000..236f55de8 --- /dev/null +++ b/modular/console/publish_command.go @@ -0,0 +1,103 @@ +package console + +import ( + "path/filepath" + "runtime/debug" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" + contractsmodular "github.com/goravel/framework/contracts/modular" + "github.com/goravel/framework/errors" + "github.com/goravel/framework/modular" + "github.com/goravel/framework/support/path" +) + +type PublishCommand struct { + registry contractsmodular.Registry + // buildInfo names the Go module and version of third-party modules. + buildInfo func() (*debug.BuildInfo, bool) +} + +func NewPublishCommand(registry contractsmodular.Registry) *PublishCommand { + return &PublishCommand{registry: registry, buildInfo: debug.ReadBuildInfo} +} + +// Signature The name and signature of the console command. +func (r *PublishCommand) Signature() string { + return "modular:publish" +} + +// Description The console command description. +func (r *PublishCommand) Description() string { + return "Copy the sources of third-party modules into the project" +} + +// Extend The console command extend. +func (r *PublishCommand) Extend() command.Extend { + return command.Extend{ + Category: "modular", + Flags: []command.Flag{ + &command.StringFlag{ + Name: "module", + Usage: "Publish only the given module", + }, + }, + } +} + +// Handle Execute the console command. It publishes every module and fails when one could not be. +func (r *PublishCommand) Handle(ctx console.Context) error { + if r.registry == nil { + return errors.ModularRegistryNotSet + } + + modules := r.registry.All() + if name := ctx.Option("module"); name != "" { + module, ok := r.registry.Find(name) + if !ok { + return errors.ModularModuleNotFound.Args(name) + } + modules = []contractsmodular.Module{module} + } + + info, _ := r.buildInfo() + + failed := 0 + for _, module := range modules { + status, err := r.publish(module, info) + if err != nil { + status = statusFailed + ctx.Error(err.Error()) + failed++ + } + ctx.TwoColumnDetail(module.Name(), status) + } + if failed > 0 { + return errors.ModularPublishFailed.Args(failed, len(modules)) + } + + return nil +} + +func (r *PublishCommand) publish(module contractsmodular.Module, info *debug.BuildInfo) (string, error) { + dependency, ok := modular.DependencyOf(module, info) + if !ok { + return statusSkippedLocal, nil + } + + dir, _ := r.registry.Path(module.Name()) + changed, err := modular.Publish(module, dependency, path.Base(filepath.FromSlash(dir))) + switch { + case errors.Is(err, errors.ModularPublishTargetNotOwned): + // Name the directory as the other messages do, relative to the project. + return "", errors.ModularPublishTargetNotOwned.Args(module.Name(), dir, dependency.Path) + case err != nil: + return "", err + case changed: + return statusPublished, nil + case modular.PublishState(module, dependency, path.Base(filepath.FromSlash(dir))) == modular.PublishNone: + return statusSkippedNone, nil + default: + return statusUnchanged, nil + } +} diff --git a/modular/console/publish_command_test.go b/modular/console/publish_command_test.go new file mode 100644 index 000000000..252647e72 --- /dev/null +++ b/modular/console/publish_command_test.go @@ -0,0 +1,149 @@ +package console + +import ( + "io/fs" + "runtime/debug" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/suite" + + contractsmodular "github.com/goravel/framework/contracts/modular" + "github.com/goravel/framework/errors" + mocksconsole "github.com/goravel/framework/mocks/console" + mocksmodular "github.com/goravel/framework/mocks/modular" + "github.com/goravel/framework/modular" + "github.com/goravel/framework/support/file" + "github.com/goravel/framework/support/path" +) + +type testModule struct{ name string } + +func (m *testModule) Name() string { return m.name } + +type sourcesModule struct { + testModule + sources fs.FS +} + +func (m *sourcesModule) Sources() fs.FS { return m.sources } + +type locatedModule struct{ sourcesModule } + +func (m *locatedModule) Path() string { return "modules/local" } + +// thirdPartyBuild makes this package's test modules third-party: the app is another Go module. +func thirdPartyBuild() (*debug.BuildInfo, bool) { + return &debug.BuildInfo{ + Main: debug.Module{Path: "example.com/app"}, + Deps: []*debug.Module{{Path: "github.com/goravel/framework", Version: "v1.3.0", Sum: "h1:abc"}}, + }, true +} + +type PublishCommandTestSuite struct { + suite.Suite +} + +func TestPublishCommandTestSuite(t *testing.T) { + suite.Run(t, new(PublishCommandTestSuite)) +} + +func (s *PublishCommandTestSuite) TearDownTest() { + removeScaffold(s.T()) +} + +func (s *PublishCommandTestSuite) command(registry contractsmodular.Registry) *PublishCommand { + cmd := NewPublishCommand(registry) + cmd.buildInfo = thirdPartyBuild + + return cmd +} + +func (s *PublishCommandTestSuite) TestSignature() { + cmd := NewPublishCommand(nil) + + s.Equal("modular:publish", cmd.Signature()) + s.Equal("Copy the sources of third-party modules into the project", cmd.Description()) + s.Equal("modular", cmd.Extend().Category) + s.Len(cmd.Extend().Flags, 1) +} + +func (s *PublishCommandTestSuite) TestRegistryNotSet() { + s.Equal(errors.ModularRegistryNotSet, NewPublishCommand(nil).Handle(mocksconsole.NewContext(s.T()))) +} + +func (s *PublishCommandTestSuite) TestPublishesThirdPartyModules() { + billing := &sourcesModule{testModule{"billing"}, fstest.MapFS{"resources/js/app.js": {Data: []byte("app")}}} + plain := &testModule{"plain"} + local := &locatedModule{sourcesModule{testModule{"local"}, fstest.MapFS{"app.js": {Data: []byte("app")}}}} + + registry := mocksmodular.NewRegistry(s.T()) + registry.EXPECT().All().Return([]contractsmodular.Module{billing, plain, local}) + registry.EXPECT().Path("billing").Return("modules/billing", true) + registry.EXPECT().Path("plain").Return("modules/plain", true) + + ctx := mocksconsole.NewContext(s.T()) + ctx.EXPECT().Option("module").Return("").Once() + ctx.EXPECT().TwoColumnDetail("billing", statusPublished).Once() + ctx.EXPECT().TwoColumnDetail("plain", statusSkippedNone).Once() + ctx.EXPECT().TwoColumnDetail("local", statusSkippedLocal).Once() + + s.NoError(s.command(registry).Handle(ctx)) + + s.True(file.Exists("modules/billing/resources/js/app.js")) + s.True(file.Contain("modules/billing/"+modular.PublishMarker, `"version": "v1.3.0"`)) + s.False(file.Exists("modules/plain")) + s.False(file.Exists("modules/local")) + s.Equal(modular.PublishPublished, modular.PublishState(billing, modular.Dependency{Path: "github.com/goravel/framework", Version: "v1.3.0", Sum: "h1:abc"}, path.Base("modules", "billing"))) + + ctx = mocksconsole.NewContext(s.T()) + ctx.EXPECT().Option("module").Return("").Once() + ctx.EXPECT().TwoColumnDetail("billing", statusUnchanged).Once() + ctx.EXPECT().TwoColumnDetail("plain", statusSkippedNone).Once() + ctx.EXPECT().TwoColumnDetail("local", statusSkippedLocal).Once() + + s.NoError(s.command(registry).Handle(ctx)) +} + +func (s *PublishCommandTestSuite) TestModuleOption() { + billing := &sourcesModule{testModule{"billing"}, fstest.MapFS{"app.js": {Data: []byte("app")}}} + + registry := mocksmodular.NewRegistry(s.T()) + registry.EXPECT().All().Return([]contractsmodular.Module{billing, &testModule{"plain"}}) + registry.EXPECT().Find("billing").Return(billing, true).Once() + registry.EXPECT().Find("shop").Return(nil, false).Once() + registry.EXPECT().Path("billing").Return("modules/billing", true) + + ctx := mocksconsole.NewContext(s.T()) + ctx.EXPECT().Option("module").Return("billing").Once() + ctx.EXPECT().TwoColumnDetail("billing", statusPublished).Once() + + s.NoError(s.command(registry).Handle(ctx)) + + ctx = mocksconsole.NewContext(s.T()) + ctx.EXPECT().Option("module").Return("shop").Once() + + s.Equal(errors.ModularModuleNotFound.Args("shop"), s.command(registry).Handle(ctx)) +} + +func (s *PublishCommandTestSuite) TestFailureDoesNotStopOtherModules() { + billing := &sourcesModule{testModule{"billing"}, fstest.MapFS{"app.js": {Data: []byte("app")}}} + shop := &sourcesModule{testModule{"shop"}, fstest.MapFS{"app.js": {Data: []byte("app")}}} + s.Require().NoError(file.PutContent("modules/billing/module.go", "package billing\n")) + + registry := mocksmodular.NewRegistry(s.T()) + registry.EXPECT().All().Return([]contractsmodular.Module{billing, shop}) + registry.EXPECT().Path("billing").Return("modules/billing", true) + registry.EXPECT().Path("shop").Return("modules/shop", true) + + notOwned := errors.ModularPublishTargetNotOwned.Args("billing", "modules/billing", "github.com/goravel/framework") + ctx := mocksconsole.NewContext(s.T()) + ctx.EXPECT().Option("module").Return("").Once() + ctx.EXPECT().Error(notOwned.Error()).Once() + ctx.EXPECT().TwoColumnDetail("billing", statusFailed).Once() + ctx.EXPECT().TwoColumnDetail("shop", statusPublished).Once() + + s.Equal(errors.ModularPublishFailed.Args(1, 2), s.command(registry).Handle(ctx)) + s.False(file.Exists("modules/billing/app.js")) + s.True(file.Exists("modules/shop/app.js")) +} diff --git a/modular/publish.go b/modular/publish.go new file mode 100644 index 000000000..3fa837e04 --- /dev/null +++ b/modular/publish.go @@ -0,0 +1,279 @@ +package modular + +import ( + "bytes" + "encoding/json" + "io/fs" + "maps" + "os" + stdpath "path" + "path/filepath" + "runtime/debug" + "slices" + "strings" + + "github.com/goravel/framework/contracts/modular" + "github.com/goravel/framework/errors" +) + +const ( + // PublishMarker records what modular:publish wrote into a module directory. + PublishMarker = ".modular-publish.json" + // PublishNotice tells readers the directory is generated. + PublishNotice = "DO-NOT-EDIT.md" + + // Publish states of a module, as shown by modular:list. + PublishLocal = "local" // in the project, nothing is copied + PublishNone = "none" // third-party without sources or views + PublishPublished = "published" // copied from the linked version + PublishStale = "stale" // differs from the linked version + PublishMissing = "missing" // not copied yet + PublishConflict = "conflict" // the directory holds something else +) + +// Dependency is the Go module a third-party module is linked from. +type Dependency struct { + Path string `json:"module"` + Version string `json:"version"` + Sum string `json:"sum"` +} + +type publishMarker struct { + Dependency + Files []string `json:"files"` +} + +// DependencyOf returns the Go module of a third-party module. ok is false for a +// module of the application and for one that sets its own Path(). +func DependencyOf(module modular.Module, info *debug.BuildInfo) (Dependency, bool) { + if _, located := module.(modular.Located); located || info == nil { + return Dependency{}, false + } + + pkg := packagePath(module) + if pkg == "" || pkg == "main" || within(pkg, info.Main.Path) { + return Dependency{}, false + } + + // Without a matching dependency (workspace builds) the package stands in for it. + dependency := Dependency{Path: pkg} + found := "" + for _, dep := range info.Deps { + if dep == nil || !within(pkg, dep.Path) || len(dep.Path) <= len(found) { + continue + } + + found = dep.Path + linked := dep + if dep.Replace != nil { + linked = dep.Replace + } + dependency = Dependency{Path: dep.Path, Version: linked.Version, Sum: linked.Sum} + } + + return dependency, true +} + +// PublishState reports whether dir holds the copy of module published from dependency. +func PublishState(module modular.Module, dependency Dependency, dir string) string { + files, err := publishFiles(module, dependency) + if err != nil || len(files) == 0 { + return PublishNone + } + + marker, owned := readPublishMarker(dir) + if !owned || marker.Path != dependency.Path { + if emptyDir(dir) { + return PublishMissing + } + + return PublishConflict + } + if marker.Dependency != dependency || !slices.Equal(marker.Files, slices.Sorted(maps.Keys(files))) { + return PublishStale + } + // The version alone misses a replaced module and edited copies: compare the files too. + for name, content := range files { + current, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(name))) + if err != nil || !bytes.Equal(current, content) { + return PublishStale + } + } + + return PublishPublished +} + +// Publish copies the module's sources, and its views unless the sources hold +// them, into dir. It reports whether anything was written or removed. +func Publish(module modular.Module, dependency Dependency, dir string) (bool, error) { + files, err := publishFiles(module, dependency) + if err != nil || len(files) == 0 { + return false, err + } + + marker, owned := readPublishMarker(dir) + if owned && marker.Path != dependency.Path { + owned = false + } + if !owned && !emptyDir(dir) { + return false, errors.ModularPublishTargetNotOwned.Args(module.Name(), dir, dependency.Path) + } + + names := slices.Sorted(maps.Keys(files)) + + changed := marker.Dependency != dependency || !slices.Equal(marker.Files, names) + for _, name := range names { + target := filepath.Join(dir, filepath.FromSlash(name)) + if current, err := os.ReadFile(target); err == nil && bytes.Equal(current, files[name]) { + continue + } + + changed = true + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return false, err + } + if err := os.WriteFile(target, files[name], 0o644); err != nil { + return false, err + } + } + + // Remove what an earlier publish wrote and the module no longer ships. + for _, name := range marker.Files { + if _, ok := files[name]; ok { + continue + } + if clean := stdpath.Clean(name); !fs.ValidPath(clean) || clean == "." { + continue + } + + target := filepath.Join(dir, filepath.FromSlash(name)) + if err := os.Remove(target); err != nil && !os.IsNotExist(err) { + return false, err + } + removeEmptyDirs(filepath.Dir(target), dir) + } + + if !changed { + return false, nil + } + + content, err := json.MarshalIndent(publishMarker{Dependency: dependency, Files: names}, "", " ") + if err != nil { + return false, err + } + + return true, os.WriteFile(filepath.Join(dir, PublishMarker), append(content, '\n'), 0o644) +} + +// publishFiles returns the files to write by slash path, empty when the module ships none. +func publishFiles(module modular.Module, dependency Dependency) (map[string][]byte, error) { + files := make(map[string][]byte) + + if p, ok := module.(modular.ProvidesSources); ok { + if err := readFiles(p.Sources(), "", files); err != nil { + return nil, err + } + } + + if p, ok := module.(modular.ProvidesViews); ok && !hasPrefix(files, viewsDirectory+"/") { + if err := readFiles(p.Views(), viewsDirectory, files); err != nil { + return nil, err + } + } + + if len(files) == 0 { + return files, nil + } + delete(files, PublishMarker) + files[PublishNotice] = []byte(publishNotice(module.Name(), dependency)) + + return files, nil +} + +// viewsDirectory is where a module's views live inside its directory. +const viewsDirectory = "resources/views" + +func readFiles(fsys fs.FS, prefix string, files map[string][]byte) error { + if fsys == nil { + return nil + } + + return fs.WalkDir(fsys, ".", func(name string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || isGoSource(name) { + return err + } + + content, err := fs.ReadFile(fsys, name) + if err != nil { + return err + } + files[stdpath.Join(prefix, name)] = content + + return nil + }) +} + +// isGoSource reports files that would be compiled into the application, or cut the +// directory out of it: they are never published. +func isGoSource(name string) bool { + base := stdpath.Base(name) + + return strings.HasSuffix(base, ".go") || base == "go.mod" || base == "go.sum" +} + +func readPublishMarker(dir string) (publishMarker, bool) { + var marker publishMarker + content, err := os.ReadFile(filepath.Join(dir, PublishMarker)) + if err != nil || json.Unmarshal(content, &marker) != nil { + return publishMarker{}, false + } + + return marker, true +} + +func publishNotice(name string, dependency Dependency) string { + source := dependency.Path + if dependency.Version != "" { + source += " " + dependency.Version + } + + return "# Do not edit\n\n" + + "This directory is generated by `artisan modular:publish` from the module `" + name + "` (" + source + ").\n" + + "Changes are overwritten by the next publish; run `go generate` after upgrading the module.\n" + + "The files are here for tools that read sources (Vite, Tailwind); views are rendered from the copy embedded in the module.\n" +} + +// within reports whether pkg is the Go module root or a package inside it. +func within(pkg, root string) bool { + return root != "" && (pkg == root || strings.HasPrefix(pkg, root+"/")) +} + +func hasPrefix(files map[string][]byte, prefix string) bool { + for name := range files { + if strings.HasPrefix(name, prefix) { + return true + } + } + + return false +} + +// emptyDir reports whether dir is missing or has no entries. +func emptyDir(dir string) bool { + entries, err := os.ReadDir(dir) + if err != nil { + return os.IsNotExist(err) + } + + return len(entries) == 0 +} + +// removeEmptyDirs removes dir and its empty parents, stopping at root. +func removeEmptyDirs(dir, root string) { + for dir != root && strings.HasPrefix(dir, root) { + if os.Remove(dir) != nil { + return + } + dir = filepath.Dir(dir) + } +} diff --git a/modular/publish_test.go b/modular/publish_test.go new file mode 100644 index 000000000..bea85b6be --- /dev/null +++ b/modular/publish_test.go @@ -0,0 +1,251 @@ +package modular + +import ( + "io/fs" + "os" + "path/filepath" + "runtime/debug" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/goravel/framework/errors" +) + +// Test modules live in this package, so an app with another main module sees them as third-party. +const publishPackage = "github.com/goravel/framework/modular" + +type sourcesModule struct { + plainModule + sources fs.FS + views fs.FS +} + +func (m *sourcesModule) Sources() fs.FS { return m.sources } +func (m *sourcesModule) Views() fs.FS { return m.views } + +type locatedSourcesModule struct{ sourcesModule } + +func (m *locatedSourcesModule) Path() string { return "vendor/billing" } + +func buildInfo(deps ...*debug.Module) *debug.BuildInfo { + return &debug.BuildInfo{Main: debug.Module{Path: "example.com/app"}, Deps: deps} +} + +func TestDependencyOf(t *testing.T) { + module := &sourcesModule{plainModule: plainModule{"billing"}} + framework := &debug.Module{Path: "github.com/goravel/framework", Version: "v1.3.0", Sum: "h1:abc"} + + t.Run("third-party", func(t *testing.T) { + dependency, ok := DependencyOf(module, buildInfo(&debug.Module{Path: "github.com/goravel"}, framework, &debug.Module{Path: "github.com/goravel/framework/mod"})) + + assert.True(t, ok) + assert.Equal(t, Dependency{Path: "github.com/goravel/framework", Version: "v1.3.0", Sum: "h1:abc"}, dependency) + }) + + t.Run("replaced", func(t *testing.T) { + replaced := &debug.Module{Path: "github.com/goravel/framework", Version: "v1.3.0", Replace: &debug.Module{Path: "../framework", Version: "(devel)"}} + dependency, ok := DependencyOf(module, buildInfo(replaced)) + + assert.True(t, ok) + assert.Equal(t, Dependency{Path: "github.com/goravel/framework", Version: "(devel)"}, dependency) + }) + + t.Run("no matching dependency", func(t *testing.T) { + dependency, ok := DependencyOf(module, buildInfo()) + + assert.True(t, ok) + assert.Equal(t, Dependency{Path: publishPackage}, dependency) + }) + + t.Run("application module", func(t *testing.T) { + _, ok := DependencyOf(module, &debug.BuildInfo{Main: debug.Module{Path: "github.com/goravel/framework"}}) + assert.False(t, ok) + }) + + t.Run("located module", func(t *testing.T) { + _, ok := DependencyOf(&locatedSourcesModule{}, buildInfo(framework)) + assert.False(t, ok) + }) + + t.Run("no build info", func(t *testing.T) { + _, ok := DependencyOf(module, nil) + assert.False(t, ok) + }) +} + +func TestPublish(t *testing.T) { + dependency := Dependency{Path: "github.com/acme/billing", Version: "v1.2.0", Sum: "h1:abc"} + read := func(t *testing.T, dir, name string) string { + content, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(name))) + require.NoError(t, err) + + return string(content) + } + + t.Run("writes sources, views, notice and marker", func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "modules", "billing") + module := &sourcesModule{ + plainModule: plainModule{"billing"}, + sources: fstest.MapFS{ + "resources/js/app.js": {Data: []byte("app")}, + "resources/js/legacy.js": {Data: []byte("legacy")}, + }, + views: fstest.MapFS{"layouts/base.tmpl": {Data: []byte("base")}}, + } + assert.Equal(t, PublishMissing, PublishState(module, dependency, dir)) + + changed, err := Publish(module, dependency, dir) + require.NoError(t, err) + assert.True(t, changed) + assert.Equal(t, "app", read(t, dir, "resources/js/app.js")) + assert.Equal(t, "base", read(t, dir, "resources/views/layouts/base.tmpl")) + assert.Contains(t, read(t, dir, PublishNotice), "`billing` (github.com/acme/billing v1.2.0)") + assert.JSONEq(t, `{ + "module": "github.com/acme/billing", "version": "v1.2.0", "sum": "h1:abc", + "files": ["DO-NOT-EDIT.md", "resources/js/app.js", "resources/js/legacy.js", "resources/views/layouts/base.tmpl"] + }`, read(t, dir, PublishMarker)) + assert.Equal(t, PublishPublished, PublishState(module, dependency, dir)) + + // Unchanged the second time. + changed, err = Publish(module, dependency, dir) + require.NoError(t, err) + assert.False(t, changed) + + // A new version drops a file, changes one, and keeps files it did not write. + require.NoError(t, os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("mine"), 0o644)) + upgraded := Dependency{Path: dependency.Path, Version: "v1.3.0", Sum: "h1:def"} + module.sources = fstest.MapFS{"resources/js/app.js": {Data: []byte("app v2")}} + module.views = nil + assert.Equal(t, PublishStale, PublishState(module, upgraded, dir)) + + changed, err = Publish(module, upgraded, dir) + require.NoError(t, err) + assert.True(t, changed) + assert.Equal(t, "app v2", read(t, dir, "resources/js/app.js")) + assert.Equal(t, "mine", read(t, dir, "notes.txt")) + assert.NoFileExists(t, filepath.Join(dir, "resources/js/legacy.js")) + assert.NoDirExists(t, filepath.Join(dir, "resources/views")) + assert.Contains(t, read(t, dir, PublishMarker), `"version": "v1.3.0"`) + assert.Equal(t, PublishPublished, PublishState(module, upgraded, dir)) + }) + + t.Run("views inside the sources are not published twice", func(t *testing.T) { + dir := t.TempDir() + module := &sourcesModule{ + plainModule: plainModule{"billing"}, + sources: fstest.MapFS{"resources/views/index.tmpl": {Data: []byte("source")}}, + views: fstest.MapFS{"index.tmpl": {Data: []byte("embedded")}, "extra.tmpl": {Data: []byte("extra")}}, + } + + changed, err := Publish(module, dependency, dir) + require.NoError(t, err) + assert.True(t, changed) + assert.Equal(t, "source", read(t, dir, "resources/views/index.tmpl")) + assert.NoFileExists(t, filepath.Join(dir, "resources/views/extra.tmpl")) + }) + + t.Run("nothing to publish", func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "billing") + module := &sourcesModule{plainModule: plainModule{"billing"}} + + changed, err := Publish(module, dependency, dir) + require.NoError(t, err) + assert.False(t, changed) + assert.NoDirExists(t, dir) + assert.Equal(t, PublishNone, PublishState(module, dependency, dir)) + }) + + t.Run("refuses a directory it does not own", func(t *testing.T) { + module := &sourcesModule{plainModule: plainModule{"billing"}, sources: fstest.MapFS{"app.js": {Data: []byte("app")}}} + + // Someone's code without a marker. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "module.go"), []byte("package billing"), 0o644)) + _, err := Publish(module, dependency, dir) + assert.Equal(t, errors.ModularPublishTargetNotOwned.Args("billing", dir, dependency.Path), err) + assert.NoFileExists(t, filepath.Join(dir, "app.js")) + + // Published from another Go module. + dir = t.TempDir() + _, err = Publish(module, Dependency{Path: "github.com/other/billing"}, dir) + require.NoError(t, err) + _, err = Publish(module, dependency, dir) + assert.Equal(t, errors.ModularPublishTargetNotOwned.Args("billing", dir, dependency.Path), err) + assert.Equal(t, PublishConflict, PublishState(module, dependency, dir)) + }) + + t.Run("state follows the files, not only the version", func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "billing") + module := &sourcesModule{plainModule: plainModule{"billing"}, sources: fstest.MapFS{"app.js": {Data: []byte("app")}}} + replaced := Dependency{Path: dependency.Path, Version: "(devel)"} + + _, err := Publish(module, replaced, dir) + require.NoError(t, err) + assert.Equal(t, PublishPublished, PublishState(module, replaced, dir)) + + // The module changed without a new version. + module.sources = fstest.MapFS{"app.js": {Data: []byte("app v2")}} + assert.Equal(t, PublishStale, PublishState(module, replaced, dir)) + changed, err := Publish(module, replaced, dir) + require.NoError(t, err) + assert.True(t, changed) + assert.Equal(t, PublishPublished, PublishState(module, replaced, dir)) + + // The copy was edited or lost a file. + require.NoError(t, os.WriteFile(filepath.Join(dir, "app.js"), []byte("edited"), 0o644)) + assert.Equal(t, PublishStale, PublishState(module, replaced, dir)) + require.NoError(t, os.Remove(filepath.Join(dir, "app.js"))) + assert.Equal(t, PublishStale, PublishState(module, replaced, dir)) + + // The module ships another file. + module.sources = fstest.MapFS{"app.js": {Data: []byte("app v2")}, "extra.js": {Data: []byte("extra")}} + _, err = Publish(module, replaced, dir) + require.NoError(t, err) + module.sources = fstest.MapFS{"app.js": {Data: []byte("app v2")}} + assert.Equal(t, PublishStale, PublishState(module, replaced, dir)) + }) + + t.Run("conflict when the directory is not a publish of the module", func(t *testing.T) { + module := &sourcesModule{plainModule: plainModule{"billing"}, sources: fstest.MapFS{"app.js": {Data: []byte("app")}}} + + dir := t.TempDir() + assert.Equal(t, PublishMissing, PublishState(module, dependency, dir)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "module.go"), []byte("package billing"), 0o644)) + assert.Equal(t, PublishConflict, PublishState(module, dependency, dir)) + + require.NoError(t, os.WriteFile(filepath.Join(dir, PublishMarker), []byte("{"), 0o644)) + assert.Equal(t, PublishConflict, PublishState(module, dependency, dir)) + }) + + t.Run("go files are never published", func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "billing") + module := &sourcesModule{ + plainModule: plainModule{"billing"}, + sources: fstest.MapFS{ + "module.go": {Data: []byte("package billing")}, + "go.mod": {Data: []byte("module x")}, + "go.sum": {Data: []byte("")}, + "http/controller.go": {Data: []byte("package http")}, + "resources/js/app.js": {Data: []byte("app")}, + "resources/go.md": {Data: []byte("kept")}, + }, + } + + _, err := Publish(module, dependency, dir) + require.NoError(t, err) + for _, name := range []string{"module.go", "go.mod", "go.sum", "http/controller.go", "http"} { + assert.NoFileExists(t, filepath.Join(dir, name)) + assert.NoDirExists(t, filepath.Join(dir, name)) + } + assert.Equal(t, "app", read(t, dir, "resources/js/app.js")) + assert.Equal(t, "kept", read(t, dir, "resources/go.md")) + assert.NotContains(t, read(t, dir, PublishMarker), ".go\"") + + // Only Go files: nothing to publish. + onlyGo := &sourcesModule{plainModule: plainModule{"billing"}, sources: fstest.MapFS{"module.go": {Data: []byte("package billing")}}} + assert.Equal(t, PublishNone, PublishState(onlyGo, dependency, t.TempDir())) + }) +} diff --git a/modular/registry.go b/modular/registry.go index bea6e6b7b..e72eef252 100644 --- a/modular/registry.go +++ b/modular/registry.go @@ -110,6 +110,7 @@ func (r *Registry) Capabilities(module modular.Module) map[string]bool { "vite-entries": implements[modular.ProvidesViteEntries](module), "vite-aliases": implements[modular.ProvidesViteAlias](module), "static": implements[modular.ProvidesStatic](module), + "sources": implements[modular.ProvidesSources](module), } } diff --git a/modular/registry_test.go b/modular/registry_test.go index 7413632bf..2bf84c584 100644 --- a/modular/registry_test.go +++ b/modular/registry_test.go @@ -109,7 +109,7 @@ func TestRegistry_Capabilities(t *testing.T) { registry := NewRegistry("") capabilities := registry.Capabilities(&routedModule{plainModule{"blog"}}) - assert.Len(t, capabilities, 16) + assert.Len(t, capabilities, 17) for label, ok := range capabilities { assert.Equal(t, label == "routes" || label == "services", ok, label) } From 69b6712547761346fe41b71aeb8ceacdcdd2c418 Mon Sep 17 00:00:00 2001 From: Saad Date: Wed, 11 Nov 2026 12:54:27 +0600 Subject: [PATCH 13/29] feat(modular): add modular:list artisan modular:list shows each registered module with its directory, publish state and order, followed by its capabilities, Vite entries, Vite aliases and static prefixes. --json writes the same as one JSON array (name, path, order, publish, capabilities, vite_entries, vite_aliases, static) straight to stdout, without the colour codes ctx.Line adds, so it can be piped. Without modules it points at bootstrap/modules.go; without a registry (application not built with foundation.Setup()) it returns ModularRegistryNotSet. --- foundation/application.go | 1 + modular/console/list_command.go | 168 +++++++++++++++++++++++++++ modular/console/list_command_test.go | 143 +++++++++++++++++++++++ 3 files changed, 312 insertions(+) create mode 100644 modular/console/list_command.go create mode 100644 modular/console/list_command_test.go diff --git a/foundation/application.go b/foundation/application.go index bc2acbacd..646c9d524 100644 --- a/foundation/application.go +++ b/foundation/application.go @@ -1035,6 +1035,7 @@ func (r *Application) defaultCommands() []contractsconsole.Command { console.NewPackageUninstallCommand(binding.Bindings, r.MakeProcess(), r.Json()), console.NewVendorPublishCommand(r.publishes, r.publishGroups), modularconsole.NewInitCommand(r.MakeConfig()), + modularconsole.NewListCommand(r.MakeModular()), modularconsole.NewModuleMakeCommand(r.MakeConfig()), modularconsole.NewPublishCommand(r.MakeModular()), } diff --git a/modular/console/list_command.go b/modular/console/list_command.go new file mode 100644 index 000000000..4c76e1a2c --- /dev/null +++ b/modular/console/list_command.go @@ -0,0 +1,168 @@ +package console + +import ( + "encoding/json" + "fmt" + "io" + "maps" + "os" + "path/filepath" + "runtime/debug" + "slices" + "strings" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" + contractsmodular "github.com/goravel/framework/contracts/modular" + "github.com/goravel/framework/errors" + "github.com/goravel/framework/modular" + "github.com/goravel/framework/support/path" +) + +type ListCommand struct { + registry contractsmodular.Registry + buildInfo func() (*debug.BuildInfo, bool) + // out receives --json as is: ctx.Line would wrap it in colour codes. + out io.Writer +} + +func NewListCommand(registry contractsmodular.Registry) *ListCommand { + return &ListCommand{registry: registry, buildInfo: debug.ReadBuildInfo, out: os.Stdout} +} + +// moduleInfo is one module in the --json output. +type moduleInfo struct { + Name string `json:"name"` + Path string `json:"path"` + Order int `json:"order"` + Publish string `json:"publish"` + Capabilities map[string]bool `json:"capabilities"` + ViteEntries []string `json:"vite_entries"` + ViteAliases map[string]string `json:"vite_aliases"` + Static []string `json:"static"` +} + +// Signature The name and signature of the console command. +func (r *ListCommand) Signature() string { + return "modular:list" +} + +// Description The console command description. +func (r *ListCommand) Description() string { + return "List the registered modules" +} + +// Extend The console command extend. +func (r *ListCommand) Extend() command.Extend { + return command.Extend{ + Category: "modular", + Flags: []command.Flag{ + &command.BoolFlag{ + Name: "json", + Usage: "Output the modules as JSON", + }, + }, + } +} + +// Handle Execute the console command. +func (r *ListCommand) Handle(ctx console.Context) error { + if r.registry == nil { + return errors.ModularRegistryNotSet + } + + info, _ := r.buildInfo() + modules := make([]moduleInfo, 0) + for _, module := range r.registry.All() { + modules = append(modules, r.describe(module, info)) + } + + if ctx.OptionBool("json") { + content, err := json.Marshal(modules) + if err != nil { + return err + } + _, err = fmt.Fprintln(r.out, string(content)) + + return err + } + + if len(modules) == 0 { + ctx.Warning("No modules are registered. List them in bootstrap/modules.go.") + + return nil + } + + for _, module := range modules { + ctx.TwoColumnDetail( + fmt.Sprintf("%s %s", module.Name, module.Path), + fmt.Sprintf("%s, order %d", module.Publish, module.Order), + ) + for _, detail := range module.details() { + ctx.Line(" " + detail + "") + } + } + + return nil +} + +func (r *ListCommand) describe(module contractsmodular.Module, info *debug.BuildInfo) moduleInfo { + dir, _ := r.registry.Path(module.Name()) + described := moduleInfo{ + Name: module.Name(), + Path: dir, + Publish: modular.PublishLocal, + Capabilities: r.registry.Capabilities(module), + ViteEntries: []string{}, + ViteAliases: map[string]string{}, + Static: []string{}, + } + + if dependency, ok := modular.DependencyOf(module, info); ok { + described.Publish = modular.PublishState(module, dependency, path.Base(filepath.FromSlash(dir))) + } + if p, ok := module.(contractsmodular.Ordered); ok { + described.Order = p.Order() + } + if p, ok := module.(contractsmodular.ProvidesViteEntries); ok { + described.ViteEntries = append(described.ViteEntries, p.ViteEntries()...) + } + if p, ok := module.(contractsmodular.ProvidesViteAlias); ok { + maps.Copy(described.ViteAliases, p.ViteAliases()) + } + if p, ok := module.(contractsmodular.ProvidesStatic); ok { + described.Static = slices.Sorted(maps.Keys(p.Static())) + } + + return described +} + +// details are the lines under a module row: its capabilities and what they declare. +func (r moduleInfo) details() []string { + var capabilities []string + for _, label := range slices.Sorted(maps.Keys(r.Capabilities)) { + if r.Capabilities[label] { + capabilities = append(capabilities, label) + } + } + + var details []string + if len(capabilities) > 0 { + details = append(details, strings.Join(capabilities, ", ")) + } + if len(r.ViteEntries) > 0 { + details = append(details, "vite entries: "+strings.Join(r.ViteEntries, ", ")) + } + if len(r.ViteAliases) > 0 { + var aliases []string + for _, alias := range slices.Sorted(maps.Keys(r.ViteAliases)) { + aliases = append(aliases, alias+" -> "+r.ViteAliases[alias]) + } + details = append(details, "vite aliases: "+strings.Join(aliases, ", ")) + } + if len(r.Static) > 0 { + details = append(details, "static: "+strings.Join(r.Static, ", ")) + } + + return details +} diff --git a/modular/console/list_command_test.go b/modular/console/list_command_test.go new file mode 100644 index 000000000..158f63014 --- /dev/null +++ b/modular/console/list_command_test.go @@ -0,0 +1,143 @@ +package console + +import ( + "bytes" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/suite" + + contractsmodular "github.com/goravel/framework/contracts/modular" + "github.com/goravel/framework/errors" + mocksconsole "github.com/goravel/framework/mocks/console" + mocksmodular "github.com/goravel/framework/mocks/modular" + "github.com/goravel/framework/modular" + "github.com/goravel/framework/support/file" +) + +type richModule struct{ testModule } + +func (m *richModule) Order() int { return -1 } +func (m *richModule) ViteEntries() []string { return []string{"resources/js/app.js"} } +func (m *richModule) ViteAliases() map[string]string { + return map[string]string{"@blog-assets": "resources/assets"} +} +func (m *richModule) Static() map[string]contractsmodular.Path { + return map[string]contractsmodular.Path{"/blog/static": {Dir: "public"}, "/blog/docs": {Dir: "docs"}} +} + +type ListCommandTestSuite struct { + suite.Suite +} + +func TestListCommandTestSuite(t *testing.T) { + suite.Run(t, new(ListCommandTestSuite)) +} + +func (s *ListCommandTestSuite) TearDownTest() { + removeScaffold(s.T()) +} + +func (s *ListCommandTestSuite) TestSignature() { + cmd := NewListCommand(nil) + + s.Equal("modular:list", cmd.Signature()) + s.Equal("List the registered modules", cmd.Description()) + s.Equal("modular", cmd.Extend().Category) + s.Len(cmd.Extend().Flags, 1) +} + +func (s *ListCommandTestSuite) TestRegistryNotSet() { + s.Equal(errors.ModularRegistryNotSet, NewListCommand(nil).Handle(mocksconsole.NewContext(s.T()))) +} + +func (s *ListCommandTestSuite) TestEmpty() { + registry := mocksmodular.NewRegistry(s.T()) + registry.EXPECT().All().Return(nil) + + ctx := mocksconsole.NewContext(s.T()) + ctx.EXPECT().OptionBool("json").Return(false).Once() + ctx.EXPECT().Warning("No modules are registered. List them in bootstrap/modules.go.").Once() + s.NoError(NewListCommand(registry).Handle(ctx)) + + ctx = mocksconsole.NewContext(s.T()) + ctx.EXPECT().OptionBool("json").Return(true).Once() + var output bytes.Buffer + cmd := NewListCommand(registry) + cmd.out = &output + s.NoError(cmd.Handle(ctx)) + s.Equal("[]\n", output.String()) +} + +func (s *ListCommandTestSuite) TestList() { + blog := &richModule{testModule{"blog"}} + billing := &sourcesModule{testModule{"billing"}, fstest.MapFS{"app.js": {Data: []byte("app")}}} + + registry := mocksmodular.NewRegistry(s.T()) + registry.EXPECT().All().Return([]contractsmodular.Module{blog, billing}) + registry.EXPECT().Path("blog").Return("modules/blog", true) + registry.EXPECT().Path("billing").Return("modules/billing", true) + registry.EXPECT().Capabilities(blog).Return(map[string]bool{"vite-entries": true, "static": true, "views": false}) + registry.EXPECT().Capabilities(billing).Return(map[string]bool{"sources": true}) + + // Both are modules of this application: nothing is published. + ctx := mocksconsole.NewContext(s.T()) + ctx.EXPECT().OptionBool("json").Return(false).Once() + ctx.EXPECT().TwoColumnDetail("blog modules/blog", "local, order -1").Once() + ctx.EXPECT().Line(" static, vite-entries").Once() + ctx.EXPECT().Line(" vite entries: resources/js/app.js").Once() + ctx.EXPECT().Line(" vite aliases: @blog-assets -> resources/assets").Once() + ctx.EXPECT().Line(" static: /blog/docs, /blog/static").Once() + ctx.EXPECT().TwoColumnDetail("billing modules/billing", "local, order 0").Once() + ctx.EXPECT().Line(" sources").Once() + + s.NoError(NewListCommand(registry).Handle(ctx)) + + // As third-party modules: billing has sources that are not published yet. + var output bytes.Buffer + ctx = mocksconsole.NewContext(s.T()) + ctx.EXPECT().OptionBool("json").Return(true).Once() + + cmd := NewListCommand(registry) + cmd.buildInfo = thirdPartyBuild + cmd.out = &output + s.NoError(cmd.Handle(ctx)) + s.JSONEq(`[ + { + "name": "blog", "path": "modules/blog", "order": -1, "publish": "none", + "capabilities": {"vite-entries": true, "static": true, "views": false}, + "vite_entries": ["resources/js/app.js"], + "vite_aliases": {"@blog-assets": "resources/assets"}, + "static": ["/blog/docs", "/blog/static"] + }, + { + "name": "billing", "path": "modules/billing", "order": 0, "publish": "missing", + "capabilities": {"sources": true}, + "vite_entries": [], "vite_aliases": {}, "static": [] + } + ]`, output.String()) + + // Published by modular:publish. + publishCtx := mocksconsole.NewContext(s.T()) + publishCtx.EXPECT().Option("module").Return("billing").Once() + publishCtx.EXPECT().TwoColumnDetail("billing", statusPublished).Once() + registry.EXPECT().Find("billing").Return(billing, true).Once() + publish := NewPublishCommand(registry) + publish.buildInfo = thirdPartyBuild + s.NoError(publish.Handle(publishCtx)) + s.True(file.Exists("modules/billing/app.js")) + + ctx = mocksconsole.NewContext(s.T()) + ctx.EXPECT().OptionBool("json").Return(true).Once() + output.Reset() + s.NoError(cmd.Handle(ctx)) + s.Contains(output.String(), `"name":"billing","path":"modules/billing","order":0,"publish":"published"`) + + // Something else in the directory of a third-party module is a conflict. + s.Require().NoError(file.Remove("modules/billing/" + modular.PublishMarker)) + ctx = mocksconsole.NewContext(s.T()) + ctx.EXPECT().OptionBool("json").Return(true).Once() + output.Reset() + s.NoError(cmd.Handle(ctx)) + s.Contains(output.String(), `"name":"billing","path":"modules/billing","order":0,"publish":"conflict"`) +} From 53ea74e777c0e4fcfc1e574cb500c41aa5007987 Mon Sep 17 00:00:00 2001 From: Saad Date: Wed, 11 Nov 2026 20:15:11 +0600 Subject: [PATCH 14/29] feat(vite): list modules and their entries in vite:inputs vite:inputs gains "modules": [{name, path, entries, aliases}], every path relative to the project root, so goravel-vite-plugin can build module entries, add the @ aliases and watch module directories. Without a registry (legacy Boot) the list is empty. - Entries come from ProvidesViteEntries; a module without any is still listed, the plugin needs its path. - Aliases come from ProvidesViteAlias. A key must look like @ and its target must stay inside the module (ModularViteAliasInvalid); a key used by two modules, or equal to another module's implicit @, fails with ModularViteAliasDuplicate. A module may redefine its own @. - A third-party module whose published copy is missing, differs from the linked version or is not owned by modular:publish fails the command (ModularModuleNotPublished, ModularModulePublishStale, ModularPublishTargetNotOwned), so Vite never builds without its sources or with old ones. NewInputsCommand takes the module registry as a fourth argument. --- errors/list.go | 4 + vite/console/inputs_command.go | 129 +++++++++++-- vite/console/inputs_command_modules_test.go | 194 ++++++++++++++++++++ vite/console/inputs_command_test.go | 26 +-- vite/service_provider.go | 2 +- vite/service_provider_test.go | 2 + 6 files changed, 332 insertions(+), 25 deletions(-) create mode 100644 vite/console/inputs_command_modules_test.go diff --git a/errors/list.go b/errors/list.go index 1426ea22c..cc6a28cf9 100644 --- a/errors/list.go +++ b/errors/list.go @@ -227,8 +227,10 @@ var ( ModularModuleNameForbidden = New("module name %q is reserved, Go cannot import a package named %q").SetModule(ModuleModular) ModularModuleNameInvalid = New("module name %q is invalid, use letters, digits and dashes, starting with a letter").SetModule(ModuleModular) ModularModuleNotFound = New("module %s is not registered").SetModule(ModuleModular) + ModularModuleNotPublished = New("module %s (%s) is not published to %s, run `go generate` or `artisan modular:publish`").SetModule(ModuleModular) ModularModulePathInvalid = New("module %s: directory %q must be a relative path inside the project").SetModule(ModuleModular) ModularModulePathUnknown = New("module %s: cannot derive its directory from package %q, implement Path() to set it").SetModule(ModuleModular) + ModularModulePublishStale = New("module %s: the files published to %s differ from %s, run `go generate` or `artisan modular:publish`").SetModule(ModuleModular) ModularOptionsConflict = New("the --%s and --%s options cannot be used together").SetModule(ModuleModular) ModularPublishFailed = New("modular:publish failed for %d of %d modules").SetModule(ModuleModular) ModularPublishTargetNotOwned = New("module %s: %s exists and was not published from %s, move it or set the module's Path()").SetModule(ModuleModular) @@ -239,6 +241,8 @@ var ( ModularStaticDuplicate = New("static path %s of module %s is already served by module %s").SetModule(ModuleModular) ModularStaticPrefixInvalid = New("module %s: static prefix %q is invalid, use a path below /").SetModule(ModuleModular) ModularViewsFSRequired = New("module %s returned a nil views filesystem").SetModule(ModuleModular) + ModularViteAliasDuplicate = New("vite alias %s of module %s is already used by module %s").SetModule(ModuleModular) + ModularViteAliasInvalid = New("module %s: vite alias %q -> %q is invalid, use a key like @name and a relative directory inside the module").SetModule(ModuleModular) NotificationChannelNotFound = New("notification channel not found: %s").SetModule(ModuleNotification) NotificationChannelNotQueueable = New("notification channel %q does not support queued dispatch (does not implement ResolvableChannel)").SetModule(ModuleNotification) diff --git a/vite/console/inputs_command.go b/vite/console/inputs_command.go index 49374d5de..18c71e7cf 100644 --- a/vite/console/inputs_command.go +++ b/vite/console/inputs_command.go @@ -2,18 +2,25 @@ package console import ( "encoding/json" + "maps" + stdpath "path" "path/filepath" + "regexp" + "runtime/debug" "slices" "strings" contractsconfig "github.com/goravel/framework/contracts/config" "github.com/goravel/framework/contracts/console" "github.com/goravel/framework/contracts/console/command" + contractsmodular "github.com/goravel/framework/contracts/modular" contractsview "github.com/goravel/framework/contracts/view" contractsvite "github.com/goravel/framework/contracts/vite" "github.com/goravel/framework/errors" + "github.com/goravel/framework/modular" "github.com/goravel/framework/packages/paths" "github.com/goravel/framework/support" + "github.com/goravel/framework/support/path" "github.com/goravel/framework/view" ) @@ -25,13 +32,23 @@ const ( // Inputs is what goravel-vite-plugin reads: the entries passed to vite and the // files passed to viteAsset, each once and sorted, the calls with computed -// arguments, and the configured paths. The entries and assets include vite.inputs -// and vite.assets from config, so a build tool need not reach into config itself. +// arguments, the configured paths and the modules. The entries and assets include +// vite.inputs and vite.assets from config, so a build tool need not reach into config itself. type Inputs struct { - Inputs []string `json:"inputs"` - Assets []string `json:"assets"` - Dynamic []DynamicCall `json:"dynamic"` - Paths Paths `json:"paths"` + Inputs []string `json:"inputs"` + Assets []string `json:"assets"` + Dynamic []DynamicCall `json:"dynamic"` + Paths Paths `json:"paths"` + Modules []ModuleInputs `json:"modules"` +} + +// ModuleInputs is a module as the plugin needs it: its directory, entries and +// alias targets, all relative to the project root. +type ModuleInputs struct { + Name string `json:"name"` + Path string `json:"path"` + Entries []string `json:"entries"` + Aliases map[string]string `json:"aliases"` } // Paths is the vite configuration as the plugin needs it: the public directory @@ -54,13 +71,16 @@ type DynamicCall struct { // InputsCommand prints the entries, assets and build paths the views and config // declare, so a build tool need not repeat them. type InputsCommand struct { - view contractsview.View - vite contractsvite.Vite - config contractsconfig.Config + view contractsview.View + vite contractsvite.Vite + config contractsconfig.Config + modules contractsmodular.Registry + buildInfo func() (*debug.BuildInfo, bool) } -func NewInputsCommand(views contractsview.View, vite contractsvite.Vite, config contractsconfig.Config) *InputsCommand { - return &InputsCommand{view: views, vite: vite, config: config} +// NewInputsCommand takes the module registry, nil when the application has none. +func NewInputsCommand(views contractsview.View, vite contractsvite.Vite, config contractsconfig.Config, modules contractsmodular.Registry) *InputsCommand { + return &InputsCommand{view: views, vite: vite, config: config, modules: modules, buildInfo: debug.ReadBuildInfo} } // Signature The name and signature of the console command. @@ -89,6 +109,11 @@ func (r *InputsCommand) Handle(ctx console.Context) error { return errors.ViteFacadeNotSet } + modules, err := r.moduleInputs() + if err != nil { + return err + } + tmpl, err := view.Compile(view.CompileOptions{ FuncMap: r.view.Funcs(), Paths: r.view.RegisteredViews(), @@ -103,6 +128,7 @@ func (r *InputsCommand) Handle(ctx console.Context) error { Assets: r.configuredEntries("vite.assets"), Dynamic: []DynamicCall{}, Paths: r.paths(), + Modules: modules, } collect(view.Calls(tmpl, tagsFunction), tagsFunction, &inputs.Inputs, &inputs.Dynamic) collect(view.Calls(tmpl, assetFunction), assetFunction, &inputs.Assets, &inputs.Dynamic) @@ -133,6 +159,87 @@ func (r *InputsCommand) paths() Paths { } } +// aliasKey is a Vite alias a module may declare: "@" and a name without slashes. +var aliasKey = regexp.MustCompile(`^@[A-Za-z0-9][A-Za-z0-9_-]*$`) + +// moduleInputs lists every module with its entries and aliases made relative to the +// project root. It fails on an invalid or duplicate alias and on a third-party +// module whose published copy is missing or differs from the linked version. +func (r *InputsCommand) moduleInputs() ([]ModuleInputs, error) { + inputs := []ModuleInputs{} + if r.modules == nil { + return inputs, nil + } + + all := r.modules.All() + info, _ := r.buildInfo() + + // "@" belongs to its module, and every other key to the first module declaring it. + owners := make(map[string]string, len(all)) + for _, module := range all { + owners["@"+module.Name()] = module.Name() + } + + for _, module := range all { + name := module.Name() + dir, _ := r.modules.Path(name) + if err := checkPublished(module, info, dir); err != nil { + return nil, err + } + + input := ModuleInputs{Name: name, Path: dir, Entries: []string{}, Aliases: map[string]string{}} + if p, ok := module.(contractsmodular.ProvidesViteEntries); ok { + for _, entry := range p.ViteEntries() { + input.Entries = append(input.Entries, stdpath.Join(dir, filepath.ToSlash(entry))) + } + } + if p, ok := module.(contractsmodular.ProvidesViteAlias); ok { + aliases := p.ViteAliases() + for _, key := range slices.Sorted(maps.Keys(aliases)) { + target, inside := path.CleanRelative(aliases[key]) + if !aliasKey.MatchString(key) || !inside { + return nil, errors.ModularViteAliasInvalid.Args(name, key, aliases[key]) + } + if owner, taken := owners[key]; taken && owner != name { + return nil, errors.ModularViteAliasDuplicate.Args(key, name, owner) + } + + owners[key] = name + input.Aliases[key] = stdpath.Join(dir, target) + } + } + + inputs = append(inputs, input) + } + + return inputs, nil +} + +// checkPublished fails for a third-party module whose files are not in dir as +// modular:publish writes them: Vite would build without them, or with old ones. +func checkPublished(module contractsmodular.Module, info *debug.BuildInfo, dir string) error { + dependency, ok := modular.DependencyOf(module, info) + if !ok { + return nil + } + + source := dependency.Path + if dependency.Version != "" { + source += " " + dependency.Version + } + + switch modular.PublishState(module, dependency, path.Base(filepath.FromSlash(dir))) { + case modular.PublishMissing: + return errors.ModularModuleNotPublished.Args(module.Name(), source, dir) + case modular.PublishStale: + return errors.ModularModulePublishStale.Args(module.Name(), dir, source) + case modular.PublishConflict: + return errors.ModularPublishTargetNotOwned.Args(module.Name(), dir, dependency.Path) + } + + return nil +} + // configuredEntries returns vite.inputs or vite.assets as []string; empty when the // key is unset or no config is bound, so the JSON is always [] and never null. The // slice is cloned so sorting and compacting never rewrites the config's own value. diff --git a/vite/console/inputs_command_modules_test.go b/vite/console/inputs_command_modules_test.go new file mode 100644 index 000000000..32aff8fac --- /dev/null +++ b/vite/console/inputs_command_modules_test.go @@ -0,0 +1,194 @@ +package console + +import ( + "html/template" + "io/fs" + "os" + "path/filepath" + "runtime/debug" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + contractsmodular "github.com/goravel/framework/contracts/modular" + "github.com/goravel/framework/errors" + mocksconsole "github.com/goravel/framework/mocks/console" + mocksmodular "github.com/goravel/framework/mocks/modular" + mocksview "github.com/goravel/framework/mocks/view" + "github.com/goravel/framework/modular" + "github.com/goravel/framework/support" +) + +type testModule struct{ name string } + +func (m *testModule) Name() string { return m.name } + +type viteModule struct { + testModule + entries []string + aliases map[string]string +} + +func (m *viteModule) ViteEntries() []string { return m.entries } +func (m *viteModule) ViteAliases() map[string]string { return m.aliases } + +type sourcesModule struct { + testModule + sources fs.FS +} + +func (m *sourcesModule) Sources() fs.FS { return m.sources } + +// localBuild makes this package's test modules part of the application. +func localBuild() (*debug.BuildInfo, bool) { + return &debug.BuildInfo{Main: debug.Module{Path: "github.com/goravel/framework"}}, true +} + +// thirdPartyBuild makes them third-party, linked at version. +func thirdPartyBuild(version string) func() (*debug.BuildInfo, bool) { + return func() (*debug.BuildInfo, bool) { + return &debug.BuildInfo{ + Main: debug.Module{Path: "example.com/app"}, + Deps: []*debug.Module{{Path: "github.com/goravel/framework", Version: version, Sum: "h1:abc"}}, + }, true + } +} + +// moduleRegistry answers for modules placed at modules/. +func moduleRegistry(t *testing.T, modules ...contractsmodular.Module) *mocksmodular.Registry { + t.Helper() + + registry := mocksmodular.NewRegistry(t) + registry.EXPECT().All().Return(modules) + for _, module := range modules { + registry.EXPECT().Path(module.Name()).Return("modules/"+module.Name(), true).Maybe() + } + + return registry +} + +// moduleCommand builds a command over the given views; compiled is false when the +// command is expected to fail before it compiles them. +func moduleCommand(t *testing.T, registry contractsmodular.Registry, views fs.FS, compiled bool) *InputsCommand { + t.Helper() + + view := mocksview.NewView(t) + vite := defaultVite(t) + if compiled { + var registered []fs.FS + if views != nil { + registered = []fs.FS{views} + } + view.EXPECT().Funcs().Return(template.FuncMap{"vite": func(...string) template.HTML { return "" }}).Once() + view.EXPECT().RegisteredViews().Return(nil).Once() + view.EXPECT().RegisteredViewFS().Return(registered).Once() + } else { + vite.ExpectedCalls = nil + } + + cmd := NewInputsCommand(view, vite, nil, registry) + cmd.buildInfo = localBuild + + return cmd +} + +func TestInputsCommand_Modules(t *testing.T) { + setupAppViews(t, nil) + + blog := &viteModule{ + testModule: testModule{"blog"}, + entries: []string{"resources/js/app.js", "./resources/css/app.css"}, + aliases: map[string]string{"@blog-assets": "resources/assets", "@blog": "./resources"}, + } + shop := &testModule{"shop"} + views := fstest.MapFS{"blog/index.tmpl": {Data: []byte(`{{ vite "@blog/app.js" }}`)}} + + ctx := mocksconsole.NewContext(t) + ctx.EXPECT().Line(`{"inputs":["@blog/app.js"],"assets":[],"dynamic":[],"paths":{"public_directory":"public","build_directory":"build","hot_file":"public/hot","manifest":".vite/manifest.json"},` + + `"modules":[{"name":"blog","path":"modules/blog","entries":["modules/blog/resources/js/app.js","modules/blog/resources/css/app.css"],"aliases":{"@blog":"modules/blog/resources","@blog-assets":"modules/blog/resources/assets"}},` + + `{"name":"shop","path":"modules/shop","entries":[],"aliases":{}}]}`).Once() + + assert.NoError(t, moduleCommand(t, moduleRegistry(t, blog, shop), views, true).Handle(ctx)) +} + +func TestInputsCommand_ModuleAliasErrors(t *testing.T) { + setupAppViews(t, nil) + + module := func(name string, aliases map[string]string) *viteModule { + return &viteModule{testModule: testModule{name}, aliases: aliases} + } + + tests := []struct { + name string + modules []contractsmodular.Module + err error + }{ + {"escaping target", []contractsmodular.Module{module("blog", map[string]string{"@x": "../shop"})}, errors.ModularViteAliasInvalid.Args("blog", "@x", "../shop")}, + {"absolute target", []contractsmodular.Module{module("blog", map[string]string{"@x": "/etc"})}, errors.ModularViteAliasInvalid.Args("blog", "@x", "/etc")}, + {"bare @", []contractsmodular.Module{module("blog", map[string]string{"@": "."})}, errors.ModularViteAliasInvalid.Args("blog", "@", ".")}, + {"no @", []contractsmodular.Module{module("blog", map[string]string{"bad": "."})}, errors.ModularViteAliasInvalid.Args("blog", "bad", ".")}, + {"slash in key", []contractsmodular.Module{module("blog", map[string]string{"@a/b": "."})}, errors.ModularViteAliasInvalid.Args("blog", "@a/b", ".")}, + { + "two modules, one key", + []contractsmodular.Module{module("blog", map[string]string{"@ui": "ui"}), module("shop", map[string]string{"@ui": "ui"})}, + errors.ModularViteAliasDuplicate.Args("@ui", "shop", "blog"), + }, + { + "another module's implicit alias", + []contractsmodular.Module{module("blog", map[string]string{"@shop": "resources"}), module("shop", nil)}, + errors.ModularViteAliasDuplicate.Args("@shop", "blog", "shop"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cmd := moduleCommand(t, moduleRegistry(t, test.modules...), nil, false) + + assert.EqualError(t, cmd.Handle(mocksconsole.NewContext(t)), test.err.Error()) + }) + } +} + +func TestInputsCommand_ModulePublished(t *testing.T) { + setupAppViews(t, nil) + + billing := &sourcesModule{testModule{"billing"}, fstest.MapFS{"resources/js/app.js": {Data: []byte("app")}}} + plain := &testModule{"plain"} + dir := filepath.Join(support.RelativePath, "modules", "billing") + dependency := func(version string) modular.Dependency { + return modular.Dependency{Path: "github.com/goravel/framework", Version: version, Sum: "h1:abc"} + } + run := func(version string, compiled bool) error { + ctx := mocksconsole.NewContext(t) + if compiled { + ctx.EXPECT().Line(`{"inputs":[],"assets":[],"dynamic":[],"paths":{"public_directory":"public","build_directory":"build","hot_file":"public/hot","manifest":".vite/manifest.json"},` + + `"modules":[{"name":"plain","path":"modules/plain","entries":[],"aliases":{}},{"name":"billing","path":"modules/billing","entries":[],"aliases":{}}]}`).Once() + } + cmd := moduleCommand(t, moduleRegistry(t, plain, billing), nil, compiled) + cmd.buildInfo = thirdPartyBuild(version) + + return cmd.Handle(ctx) + } + + // Not published yet; a module without sources is never checked. + assert.EqualError(t, run("v1.2.0", false), errors.ModularModuleNotPublished.Args("billing", "github.com/goravel/framework v1.2.0", "modules/billing").Error()) + + _, err := modular.Publish(billing, dependency("v1.2.0"), dir) + require.NoError(t, err) + assert.NoError(t, run("v1.2.0", true)) + + // Another version is linked than the one published. + assert.EqualError(t, run("v1.3.0", false), errors.ModularModulePublishStale.Args("billing", "modules/billing", "github.com/goravel/framework v1.3.0").Error()) + + // The directory holds something modular:publish did not write. + require.NoError(t, os.Remove(filepath.Join(dir, modular.PublishMarker))) + assert.EqualError(t, run("v1.2.0", false), errors.ModularPublishTargetNotOwned.Args("billing", "modules/billing", "github.com/goravel/framework").Error()) + + // In the application itself nothing is published. + ctx := mocksconsole.NewContext(t) + ctx.EXPECT().Line(mock.Anything).Once() + assert.NoError(t, moduleCommand(t, moduleRegistry(t, billing), nil, true).Handle(ctx)) +} diff --git a/vite/console/inputs_command_test.go b/vite/console/inputs_command_test.go index 851eb3f4a..744aebf32 100644 --- a/vite/console/inputs_command_test.go +++ b/vite/console/inputs_command_test.go @@ -46,10 +46,10 @@ func defaultVite(t *testing.T) *mocksvite.Vite { return vite } -const defaultPaths = `"paths":{"public_directory":"public","build_directory":"build","hot_file":"public/hot","manifest":".vite/manifest.json"}` +const defaultPaths = `"paths":{"public_directory":"public","build_directory":"build","hot_file":"public/hot","manifest":".vite/manifest.json"},"modules":[]` func TestInputsCommand_Metadata(t *testing.T) { - cmd := NewInputsCommand(nil, nil, nil) + cmd := NewInputsCommand(nil, nil, nil, nil) assert.Equal(t, "vite:inputs", cmd.Signature()) assert.Equal(t, "Print the Vite entry points and assets the views and config declare, and the build paths, as JSON", cmd.Description()) assert.Equal(t, "vite", cmd.Extend().Category) @@ -77,7 +77,7 @@ func TestInputsCommand_Handle(t *testing.T) { ctx := mocksconsole.NewContext(t) ctx.EXPECT().Line(`{"inputs":["resources/css/app.css","resources/inertia/app.ts","resources/js/admin.js","resources/js/app.js","resources/js/mixed.js"],"assets":["resources/images/logo.svg"],"dynamic":[{"template":"dyn.tmpl","function":"vite"},{"template":"dyn.tmpl","function":"vite"}],` + defaultPaths + `}`).Once() - assert.NoError(t, NewInputsCommand(view, defaultVite(t), config).Handle(ctx)) + assert.NoError(t, NewInputsCommand(view, defaultVite(t), config, nil).Handle(ctx)) } func TestInputsCommand_Paths(t *testing.T) { @@ -99,8 +99,8 @@ func TestInputsCommand_Paths(t *testing.T) { vite.EXPECT().ManifestFilename().Return("manifest.json").Once() ctx := mocksconsole.NewContext(t) - ctx.EXPECT().Line(`{"inputs":[],"assets":[],"dynamic":[],"paths":{"public_directory":"public","build_directory":"assets","hot_file":"storage/vite.hot","manifest":"manifest.json"}}`).Once() - assert.NoError(t, NewInputsCommand(view, vite, config).Handle(ctx)) + ctx.EXPECT().Line(`{"inputs":[],"assets":[],"dynamic":[],"paths":{"public_directory":"public","build_directory":"assets","hot_file":"storage/vite.hot","manifest":"manifest.json"},"modules":[]}`).Once() + assert.NoError(t, NewInputsCommand(view, vite, config, nil).Handle(ctx)) // A hot file outside the project root stays absolute. outside := filepath.Join(t.TempDir(), "hot") @@ -110,8 +110,8 @@ func TestInputsCommand_Paths(t *testing.T) { vite.EXPECT().ManifestFilename().Return(".vite/manifest.json").Once() ctx = mocksconsole.NewContext(t) - ctx.EXPECT().Line(`{"inputs":[],"assets":[],"dynamic":[],"paths":{"public_directory":"public","build_directory":"build","hot_file":"` + filepath.ToSlash(outside) + `","manifest":".vite/manifest.json"}}`).Once() - assert.NoError(t, NewInputsCommand(view, vite, config).Handle(ctx)) + ctx.EXPECT().Line(`{"inputs":[],"assets":[],"dynamic":[],"paths":{"public_directory":"public","build_directory":"build","hot_file":"` + filepath.ToSlash(outside) + `","manifest":".vite/manifest.json"},"modules":[]}`).Once() + assert.NoError(t, NewInputsCommand(view, vite, config, nil).Handle(ctx)) } func TestInputsCommand_NoViews(t *testing.T) { @@ -129,7 +129,7 @@ func TestInputsCommand_NoViews(t *testing.T) { ctx := mocksconsole.NewContext(t) ctx.EXPECT().Line(`{"inputs":[],"assets":[],"dynamic":[],` + defaultPaths + `}`).Once() - assert.NoError(t, NewInputsCommand(view, defaultVite(t), config).Handle(ctx)) + assert.NoError(t, NewInputsCommand(view, defaultVite(t), config, nil).Handle(ctx)) } func TestInputsCommand_ConfiguredEntries(t *testing.T) { @@ -148,7 +148,7 @@ func TestInputsCommand_ConfiguredEntries(t *testing.T) { ctx := mocksconsole.NewContext(t) ctx.EXPECT().Line(`{"inputs":["resources/inertia/app.ts","resources/js/app.ts"],"assets":["resources/images/logo.svg"],"dynamic":[],` + defaultPaths + `}`).Once() - assert.NoError(t, NewInputsCommand(view, defaultVite(t), config).Handle(ctx)) + assert.NoError(t, NewInputsCommand(view, defaultVite(t), config, nil).Handle(ctx)) // The command sorts its own copy, never the value the config stores. assert.Equal(t, []string{"resources/inertia/app.ts", "resources/inertia/app.ts", "resources/js/app.ts"}, inputs) @@ -165,13 +165,13 @@ func TestInputsCommand_NilConfig(t *testing.T) { ctx := mocksconsole.NewContext(t) ctx.EXPECT().Line(`{"inputs":[],"assets":[],"dynamic":[],` + defaultPaths + `}`).Once() - assert.NoError(t, NewInputsCommand(view, defaultVite(t), nil).Handle(ctx)) + assert.NoError(t, NewInputsCommand(view, defaultVite(t), nil, nil).Handle(ctx)) } func TestInputsCommand_Errors(t *testing.T) { ctx := mocksconsole.NewContext(t) - assert.EqualError(t, NewInputsCommand(nil, nil, nil).Handle(ctx), errors.ViewFacadeNotSet.SetModule(errors.ModuleVite).Error()) - assert.EqualError(t, NewInputsCommand(mocksview.NewView(t), nil, nil).Handle(ctx), errors.ViteFacadeNotSet.Error()) + assert.EqualError(t, NewInputsCommand(nil, nil, nil, nil).Handle(ctx), errors.ViewFacadeNotSet.SetModule(errors.ModuleVite).Error()) + assert.EqualError(t, NewInputsCommand(mocksview.NewView(t), nil, nil, nil).Handle(ctx), errors.ViteFacadeNotSet.Error()) // A view that does not parse, here because vite is not registered, fails the command. setupAppViews(t, map[string]string{"broken.tmpl": `{{ vite "a" }}`}) @@ -180,7 +180,7 @@ func TestInputsCommand_Errors(t *testing.T) { view.EXPECT().RegisteredViews().Return(nil).Once() view.EXPECT().RegisteredViewFS().Return(nil).Once() - err := NewInputsCommand(view, mocksvite.NewVite(t), nil).Handle(ctx) + err := NewInputsCommand(view, mocksvite.NewVite(t), nil, nil).Handle(ctx) require.Error(t, err) assert.Contains(t, err.Error(), "[vite] failed to compile views") assert.Contains(t, err.Error(), `function "vite" not defined`) diff --git a/vite/service_provider.go b/vite/service_provider.go index 722b041d9..50e924f08 100644 --- a/vite/service_provider.go +++ b/vite/service_provider.go @@ -52,6 +52,6 @@ func (r *ServiceProvider) Boot(app foundation.Application) { } app.Commands([]contractsconsole.Command{ - console.NewInputsCommand(view, vite, app.MakeConfig()), + console.NewInputsCommand(view, vite, app.MakeConfig(), app.MakeModular()), }) } diff --git a/vite/service_provider_test.go b/vite/service_provider_test.go index 2bd3af2d4..d9506b945 100644 --- a/vite/service_provider_test.go +++ b/vite/service_provider_test.go @@ -70,6 +70,7 @@ func (s *ServiceProviderTestSuite) TestBoot() { s.app.EXPECT().MakeView().Return(view).Once() s.app.EXPECT().MakeVite().Return(NewVite(nil)).Once() s.app.EXPECT().MakeConfig().Return(mocksconfig.NewConfig(s.T())).Once() + s.app.EXPECT().MakeModular().Return(nil).Once() view.EXPECT().AddFuncs(mock.Anything).Run(func(funcs template.FuncMap) { s.Len(funcs, 3) s.Contains(funcs, "vite") @@ -95,6 +96,7 @@ func (s *ServiceProviderTestSuite) TestBoot() { s.app.EXPECT().MakeView().Return(view).Once() s.app.EXPECT().MakeVite().Return(nil).Once() s.app.EXPECT().MakeConfig().Return(mocksconfig.NewConfig(s.T())).Once() + s.app.EXPECT().MakeModular().Return(nil).Once() s.expectInputsCommand() output := color.CaptureOutput(func(io.Writer) { From 5a11ff3076a042ac19e71c0867e38f4a764ef6b3 Mon Sep 17 00:00:00 2001 From: Saad Date: Wed, 11 Nov 2026 20:31:52 +0600 Subject: [PATCH 15/29] feat(vite): resolve alias entries through the manifest and hot file Entries and assets may be written through a Vite alias, in templates and in Go: `{{ vite "@/app.js" }}`, `{{ vite "@blog/app.js" }}`, `{{ viteAsset "@blog/../assets/logo.svg" }}`. goravel-vite-plugin owns the alias table and hands it over in the two files the framework already reads: the manifest, under the reserved key "goravel:aliases", and the hot file, which becomes {"url": "...", "aliases": {...}}. A hot file holding a bare URL still works, without aliases. Tags, Asset and Content strip the leading slash, replace the longest alias matching at a path boundary (ViteAliasNotFound when none does), then clean the path and refuse one that leaves the project (ViteEntryOutsideProject). Leaving the alias directory while staying inside the project is allowed. --- errors/list.go | 16 +++-- vite/aliases_test.go | 159 +++++++++++++++++++++++++++++++++++++++++++ vite/vite.go | 123 +++++++++++++++++++++++++++------ 3 files changed, 271 insertions(+), 27 deletions(-) create mode 100644 vite/aliases_test.go diff --git a/errors/list.go b/errors/list.go index cc6a28cf9..13ae0ed4b 100644 --- a/errors/list.go +++ b/errors/list.go @@ -422,11 +422,13 @@ var ( ViewSourceWalkFailed = New("failed to walk view source %s: %v") ViewTemplateNotExist = New("view template %s does not exist") - ViteAssetOutsideBuild = New("vite asset %s resolves outside the build directory").SetModule(ModuleVite) - ViteAssetReadFailed = New("failed to read vite asset %s: %v").SetModule(ModuleVite) - ViteFacadeNotSet = New("vite facade is not initialized").SetModule(ModuleVite) - ViteEntryNotFound = New("unable to locate file in vite manifest: %s").SetModule(ModuleVite) - ViteManifestInvalid = New("vite manifest %s is invalid: %v").SetModule(ModuleVite) - ViteManifestNotFound = New("vite manifest not found at %s, run `npm run build` or start the dev server").SetModule(ModuleVite) - ViteManifestReadFailed = New("failed to read vite manifest %s: %v").SetModule(ModuleVite) + ViteAliasNotFound = New("no vite alias matches %s; aliases come from goravel-vite-plugin through the manifest (build) or the hot file (dev)").SetModule(ModuleVite) + ViteAssetOutsideBuild = New("vite asset %s resolves outside the build directory").SetModule(ModuleVite) + ViteAssetReadFailed = New("failed to read vite asset %s: %v").SetModule(ModuleVite) + ViteFacadeNotSet = New("vite facade is not initialized").SetModule(ModuleVite) + ViteEntryNotFound = New("unable to locate file in vite manifest: %s").SetModule(ModuleVite) + ViteEntryOutsideProject = New("vite entry %s resolves to %s, outside the project").SetModule(ModuleVite) + ViteManifestInvalid = New("vite manifest %s is invalid: %v").SetModule(ModuleVite) + ViteManifestNotFound = New("vite manifest not found at %s, run `npm run build` or start the dev server").SetModule(ModuleVite) + ViteManifestReadFailed = New("failed to read vite manifest %s: %v").SetModule(ModuleVite) ) diff --git a/vite/aliases_test.go b/vite/aliases_test.go new file mode 100644 index 000000000..005fdd28d --- /dev/null +++ b/vite/aliases_test.go @@ -0,0 +1,159 @@ +package vite + +import ( + "html/template" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/goravel/framework/errors" +) + +const aliasManifest = `{ + "goravel:aliases": {"@": "resources/js", "@blog": "modules/blog/resources/js", "@blog-assets": "modules/blog/resources/assets"}, + "resources/js/app.js": {"file": "assets/app-1.js", "src": "resources/js/app.js", "isEntry": true}, + "modules/blog/resources/js/app.js": {"file": "assets/blog-2.js", "src": "modules/blog/resources/js/app.js", "isEntry": true}, + "modules/blog/resources/assets/x.jpg": {"file": "assets/x-3.jpg", "src": "modules/blog/resources/assets/x.jpg"} +}` + +var testAliases = map[string]string{"@": "resources/js", "@blog": "modules/blog/resources/js"} + +func TestResolveEntry(t *testing.T) { + tests := []struct { + entry string + expected string + err error + }{ + {entry: "a/./b", expected: "a/b"}, + {entry: "/resources/js/app.js", expected: "resources/js/app.js"}, + {entry: "@/app.js", expected: "resources/js/app.js"}, + {entry: "@blog/app.js", expected: "modules/blog/resources/js/app.js"}, + {entry: "/@blog/app.js", expected: "modules/blog/resources/js/app.js"}, + {entry: "@blog", expected: "modules/blog/resources/js"}, + {entry: "@blog/../assets/x.jpg", expected: "modules/blog/resources/assets/x.jpg"}, + {entry: "..", err: errors.ViteEntryOutsideProject.Args("..", "..")}, + {entry: "../x", err: errors.ViteEntryOutsideProject.Args("../x", "../x")}, + {entry: "@blog/../../../../../.env", err: errors.ViteEntryOutsideProject.Args("@blog/../../../../../.env", "../.env")}, + {entry: "@/../../../x", err: errors.ViteEntryOutsideProject.Args("@/../../../x", "../x")}, + // Leaving the alias directory is fine while the path stays in the project. + {entry: "@/../../x", expected: "x"}, + // "@" only matches at a path boundary, so it never captures another alias. + {entry: "@shop/app.js", err: errors.ViteAliasNotFound.Args("@shop/app.js")}, + {entry: "@blogs/app.js", err: errors.ViteAliasNotFound.Args("@blogs/app.js")}, + } + + for _, test := range tests { + t.Run(test.entry, func(t *testing.T) { + resolved, err := resolveEntry(test.entry, testAliases) + if test.err != nil { + assert.EqualError(t, err, test.err.Error()) + return + } + + require.NoError(t, err) + assert.Equal(t, test.expected, resolved) + }) + } + + // Without a table plain paths still resolve. + resolved, err := resolveEntry("resources/js/app.js", nil) + require.NoError(t, err) + assert.Equal(t, "resources/js/app.js", resolved) + + _, err = resolveEntry("@/app.js", nil) + assert.EqualError(t, err, errors.ViteAliasNotFound.Args("@/app.js").Error()) +} + +func TestAliases_Build(t *testing.T) { + public := setupProject(t) + writeFile(t, filepath.Join(public, "build", ".vite", "manifest.json"), aliasManifest) + writeFile(t, filepath.Join(public, "build", "assets", "blog-2.js"), "blog()") + vite := newTestVite(t, "") + + tags, err := vite.Tags("@/app.js", "@blog/app.js", "modules/blog/resources/js/app.js") + require.NoError(t, err) + assert.Equal(t, template.HTML( + ``+ + ``, + ), tags) + + for _, asset := range []string{"@blog-assets/x.jpg", "@blog/../assets/x.jpg", "modules/blog/resources/assets/x.jpg"} { + url, err := vite.Asset(asset) + require.NoError(t, err) + assert.Equal(t, "/build/assets/x-3.jpg", url) + } + + content, err := vite.Content("@blog/app.js") + require.NoError(t, err) + assert.Equal(t, "blog()", content) + + _, err = vite.Tags("@shop/app.js") + assert.EqualError(t, err, errors.ViteAliasNotFound.Args("@shop/app.js").Error()) + _, err = vite.Asset("@blog/../../../../../.env") + assert.EqualError(t, err, errors.ViteEntryOutsideProject.Args("@blog/../../../../../.env", "../.env").Error()) + _, err = vite.Content("../x") + assert.EqualError(t, err, errors.ViteEntryOutsideProject.Args("../x", "../x").Error()) + + // The table is not a chunk. + _, err = vite.Asset("goravel:aliases") + assert.EqualError(t, err, errors.ViteEntryNotFound.Args("goravel:aliases").Error()) +} + +func TestAliases_ManifestWithoutTable(t *testing.T) { + public := setupProject(t) + writeFile(t, filepath.Join(public, "build", ".vite", "manifest.json"), sampleManifest) + vite := newTestVite(t, "") + + url, err := vite.Asset("resources/images/logo.svg") + require.NoError(t, err) + assert.Equal(t, "/build/assets/logo-11223344.svg", url) + + _, err = vite.Tags("@/app.js") + assert.EqualError(t, err, errors.ViteAliasNotFound.Args("@/app.js").Error()) +} + +func TestAliases_ManifestInvalidTable(t *testing.T) { + public := setupProject(t) + writeFile(t, filepath.Join(public, "build", ".vite", "manifest.json"), `{"goravel:aliases": ["@"]}`) + + _, err := newTestVite(t, "").Tags("resources/js/app.js") + require.Error(t, err) + assert.Contains(t, err.Error(), "is invalid") +} + +func TestAliases_Hot(t *testing.T) { + public := setupProject(t) + vite := newTestVite(t, "") + writeFile(t, filepath.Join(public, "hot"), `{"url":"http://localhost:5173/","aliases":{"@":"resources/js","@blog":"modules/blog/resources/js"}}`+"\n") + + tags, err := vite.Tags("@blog/app.js", "@/app.js", "resources/css/app.css") + require.NoError(t, err) + assert.Equal(t, template.HTML( + ``+ + ``+ + ``+ + ``, + ), tags) + + url, err := vite.Asset("@blog/../assets/x.jpg") + require.NoError(t, err) + assert.Equal(t, "http://localhost:5173/modules/blog/resources/assets/x.jpg", url) + + _, err = vite.Asset("@blog/../../../../../.env") + assert.EqualError(t, err, errors.ViteEntryOutsideProject.Args("@blog/../../../../../.env", "../.env").Error()) + _, err = vite.Tags("@shop/app.js") + assert.EqualError(t, err, errors.ViteAliasNotFound.Args("@shop/app.js").Error()) + + assert.Contains(t, string(vite.ReactRefresh()), "'http://localhost:5173/@react-refresh'") + assert.True(t, vite.IsRunningHot()) + + // A bare URL, as an older plugin writes it, has no aliases. + writeFile(t, filepath.Join(public, "hot"), "http://localhost:5173") + url, err = vite.Asset("resources/images/logo.svg") + require.NoError(t, err) + assert.Equal(t, "http://localhost:5173/resources/images/logo.svg", url) + _, err = vite.Asset("@/app.js") + assert.EqualError(t, err, errors.ViteAliasNotFound.Args("@/app.js").Error()) +} diff --git a/vite/vite.go b/vite/vite.go index dd79459cc..117ef5dbb 100644 --- a/vite/vite.go +++ b/vite/vite.go @@ -21,6 +21,7 @@ import ( "github.com/goravel/framework/errors" "github.com/goravel/framework/packages/paths" "github.com/goravel/framework/support" + supportpath "github.com/goravel/framework/support/path" ) const ( @@ -28,6 +29,9 @@ const ( defaultHotFilename = "hot" // DefaultManifestFilename is where Vite writes the manifest, relative to the build directory. DefaultManifestFilename = ".vite/manifest.json" + // aliasesKey is the manifest key under which goravel-vite-plugin writes the alias + // table, e.g. {"@": "resources/js", "@blog": "modules/blog/resources/js"}. + aliasesKey = "goravel:aliases" ) // DefaultBuildDirectory returns the default build directory, inside the public @@ -56,7 +60,8 @@ type chunk struct { // manifest is a parsed manifest.json with the size and mtime of its file, so a // rebuild is picked up without a restart. type manifest struct { - chunks map[string]chunk + chunks map[string]chunk + aliases map[string]string // importsOf is each chunk's transitive imports in preload order, computed once // when the manifest is parsed so a render does not re-walk the graph. importsOf map[string][]string @@ -66,8 +71,8 @@ type manifest struct { } // newManifest indexes the parsed chunks. -func newManifest(chunks map[string]chunk, size, modTime int64, manifestHash string) *manifest { - m := &manifest{chunks: chunks, importsOf: make(map[string][]string, len(chunks)), manifestHash: manifestHash, size: size, modTime: modTime} +func newManifest(chunks map[string]chunk, aliases map[string]string, size, modTime int64, manifestHash string) *manifest { + m := &manifest{chunks: chunks, aliases: aliases, importsOf: make(map[string][]string, len(chunks)), manifestHash: manifestHash, size: size, modTime: modTime} for name := range chunks { m.importsOf[name] = m.imports(name, nil) } @@ -139,8 +144,12 @@ func Funcs(v contractsvite.Vite) template.FuncMap { } func (r *Vite) Asset(asset string) (string, error) { - asset = normalizeEntry(asset) - if url, ok := r.hotURL(); ok { + if url, aliases, ok := r.hot(); ok { + asset, err := resolveEntry(asset, aliases) + if err != nil { + return "", err + } + return url + "/" + asset, nil } @@ -149,6 +158,11 @@ func (r *Vite) Asset(asset string) (string, error) { return "", err } + asset, err = resolveEntry(asset, m.aliases) + if err != nil { + return "", err + } + c, ok := m.chunks[asset] if !ok { return "", errors.ViteEntryNotFound.Args(asset) @@ -158,12 +172,16 @@ func (r *Vite) Asset(asset string) (string, error) { } func (r *Vite) Content(asset string) (string, error) { - asset = normalizeEntry(asset) m, err := r.manifest() if err != nil { return "", err } + asset, err = resolveEntry(asset, m.aliases) + if err != nil { + return "", err + } + c, ok := m.chunks[asset] if !ok { return "", errors.ViteEntryNotFound.Args(asset) @@ -212,7 +230,7 @@ func (r *Vite) IsRunningHot() bool { } func (r *Vite) ReactRefresh() template.HTML { - url, ok := r.hotURL() + url, _, ok := r.hot() if !ok { return "" } @@ -226,14 +244,13 @@ func (r *Vite) ReactRefresh() template.HTML { } func (r *Vite) Tags(entries ...string) (template.HTML, error) { - normalized := make([]string, len(entries)) - for i, entry := range entries { - normalized[i] = normalizeEntry(entry) - } - entries = normalized + if url, aliases, ok := r.hot(); ok { + resolved, err := resolveEntries(entries, aliases) + if err != nil { + return "", err + } - if url, ok := r.hotURL(); ok { - return r.hotTags(url, entries), nil + return r.hotTags(url, resolved), nil } m, err := r.manifest() @@ -241,7 +258,12 @@ func (r *Vite) Tags(entries ...string) (template.HTML, error) { return "", err } - return r.buildTags(m, r.assetBase(), entries) + resolved, err := resolveEntries(entries, m.aliases) + if err != nil { + return "", err + } + + return r.buildTags(m, r.assetBase(), resolved) } // UseBuildDirectory sets the build directory, given relative to the public @@ -286,6 +308,45 @@ func normalizeEntry(entry string) string { return strings.TrimPrefix(entry, "/") } +// resolveEntry turns an entry as written into the project-relative path the manifest +// and the dev server know: a leading "@alias" is replaced by its directory (longest +// alias first), then the path is cleaned and must stay inside the project. +func resolveEntry(entry string, aliases map[string]string) (string, error) { + resolved := normalizeEntry(entry) + if strings.HasPrefix(resolved, "@") { + found := "" + for alias := range aliases { + if len(alias) > len(found) && (resolved == alias || strings.HasPrefix(resolved, alias+"/")) { + found = alias + } + } + if found == "" { + return "", errors.ViteAliasNotFound.Args(entry) + } + + resolved = aliases[found] + resolved[len(found):] + } + + cleaned, ok := supportpath.CleanRelative(resolved) + if !ok { + return "", errors.ViteEntryOutsideProject.Args(entry, path.Clean(resolved)) + } + + return cleaned, nil +} + +func resolveEntries(entries []string, aliases map[string]string) ([]string, error) { + resolved := make([]string, len(entries)) + for i, entry := range entries { + var err error + if resolved[i], err = resolveEntry(entry, aliases); err != nil { + return nil, err + } + } + + return resolved, nil +} + // hotTags loads the Vite client, then every entry from the dev server. func (r *Vite) hotTags(url string, entries []string) template.HTML { var b strings.Builder @@ -377,14 +438,28 @@ func (m *manifest) imports(name string, visited map[string]struct{}) []string { return out } -// hotURL returns the dev server URL while the hot file exists. -func (r *Vite) hotURL() (string, bool) { +// hot returns the dev server URL and alias table while the hot file exists. The +// file is {"url": "...", "aliases": {...}}, or the bare URL of an older plugin. +func (r *Vite) hot() (string, map[string]string, bool) { content, err := os.ReadFile(r.HotFile()) if err != nil { - return "", false + return "", nil, false + } + + text := strings.TrimSpace(string(content)) + if !strings.HasPrefix(text, "{") { + return strings.TrimRight(text, "/"), nil, true + } + + var hot struct { + URL string `json:"url"` + Aliases map[string]string `json:"aliases"` + } + if err := json.Unmarshal([]byte(text), &hot); err != nil || hot.URL == "" { + return "", nil, false } - return strings.TrimRight(strings.TrimSpace(string(content)), "/"), true + return strings.TrimRight(hot.URL, "/"), hot.Aliases, true } // assetBase is the public URL of the build directory, prefixed with app.asset_url. @@ -445,13 +520,21 @@ func (r *Vite) manifest() (*manifest, error) { return nil, errors.ViteManifestReadFailed.Args(manifestPath, err) } + // The alias table sits among the chunks under a key no source path can have. + var table struct { + Aliases map[string]string `json:"goravel:aliases"` + } chunks := make(map[string]chunk) + if err := json.Unmarshal(content, &table); err != nil { + return nil, errors.ViteManifestInvalid.Args(manifestPath, err) + } if err := json.Unmarshal(content, &chunks); err != nil { return nil, errors.ViteManifestInvalid.Args(manifestPath, err) } + delete(chunks, aliasesKey) sum := md5.Sum(content) - m := newManifest(chunks, info.Size(), info.ModTime().UnixNano(), hex.EncodeToString(sum[:])) + m := newManifest(chunks, table.Aliases, info.Size(), info.ModTime().UnixNano(), hex.EncodeToString(sum[:])) r.manifests[manifestPath] = m return m, nil From c7896a1dac1bdf4d4b42c82ecffd97c270f43464 Mon Sep 17 00:00:00 2001 From: Saad Date: Wed, 11 Nov 2026 20:37:45 +0600 Subject: [PATCH 16/29] feat(packages): add AddToModule and match.ModuleMethod modify.AddToModule(dir, method, pkg, item) registers a generated item on a module struct, e.g. &migrations.Posts{} in Migrations(). It supports the slice capabilities: Commands, Jobs, Migrations, Seeders, ServiceProviders. - The module struct is the receiver of Name() in the module directory, module.go first. The method is edited in whichever file of the package declares it and appended after Name()'s file when the struct lacks it. - The import block of the edited file is regrouped into standard library, third-party and application imports. - match.ModuleMethod(method) matches the slice literal a method returns. createImport put a new import block after the first declaration of a file without imports, which does not compile. It is now the first declaration. --- errors/list.go | 2 + packages/match/helper.go | 16 +++ packages/match/helper_test.go | 63 +++++++-- packages/modify/utils.go | 178 ++++++++++++++++++++++++++ packages/modify/utils_test.go | 113 ++++++++++++++++ packages/modify/with_slice_actions.go | 15 +-- 6 files changed, 365 insertions(+), 22 deletions(-) diff --git a/errors/list.go b/errors/list.go index 13ae0ed4b..f72906212 100644 --- a/errors/list.go +++ b/errors/list.go @@ -294,6 +294,8 @@ var ( PackageGoModDownloadFailed = New("failed to download go.mod dependencies: %s") PackageJobsFileExists = New("jobs.go already exists but WithJobs is not registered in foundation.Setup(). The jobs.go file should only be created when adding WithJobs to Setup()") PackageMigrationsFileExists = New("migrations.go already exists but WithMigrations is not registered in foundation.Setup(). The migrations.go file should only be created when adding WithMigrations to Setup()") + PackageModuleMethodUnsupported = New("module method %s cannot be edited, use Commands, Jobs, Migrations, Seeders or ServiceProviders") + PackageModuleStructNotFound = New("no module struct found in %s, a type with a Name() method is required") PackageModulesFileExists = New("modules.go already exists but WithModules is not registered in foundation.Setup(). Add .WithModules(Modules) to Setup()") PackageProvidersFileExists = New("providers.go already exists but WithProviders is not registered in foundation.Setup(). The providers.go file should only be created when adding WithProviders to Setup()") PackageRulesFileExists = New("rules.go already exists but WithRules is not registered in foundation.Setup(). The rules.go file should only be created when adding WithRules to Setup()") diff --git a/packages/match/helper.go b/packages/match/helper.go index 8a2faa7fe..a93792d48 100644 --- a/packages/match/helper.go +++ b/packages/match/helper.go @@ -248,6 +248,22 @@ func Modules() []match.GoNode { } } +// ModuleMethod matches the slice literal returned by the method named method, e.g. +// func (m *Module) Migrations() []schema.Migration { return []schema.Migration{} }. +func ModuleMethod(method string) []match.GoNode { + return []match.GoNode{ + GoNode{ + match: func(n dst.Node) bool { + fn, ok := n.(*dst.FuncDecl) + + return ok && fn.Recv != nil && fn.Name.Name == method + }, + }, + TypeOf(&dst.ReturnStmt{}), + CompositeLit(ArrayType(AnyNode(), AnyNode())), + } +} + // Providers matches the Providers() function that returns a slice of service providers. // It looks for a function returning []foundation.ServiceProvider composite literals. // This is the recommended way to register service providers. diff --git a/packages/match/helper_test.go b/packages/match/helper_test.go index de880c8b4..7d57b8a8a 100644 --- a/packages/match/helper_test.go +++ b/packages/match/helper_test.go @@ -8,6 +8,8 @@ import ( "github.com/dave/dst" "github.com/dave/dst/decorator" "github.com/dave/dst/dstutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "github.com/goravel/framework/contracts/packages/match" @@ -15,15 +17,15 @@ import ( type MatchHelperTestSuite struct { suite.Suite - configChained *dst.File - configVariable *dst.File - providerVariable *dst.File - providerInConfig *dst.File - console *dst.File - database *dst.File - jobs *dst.File - serviceProvider *dst.File - validation *dst.File + configChained *dst.File + configVariable *dst.File + providerVariable *dst.File + providerInConfig *dst.File + console *dst.File + database *dst.File + jobs *dst.File + serviceProvider *dst.File + validation *dst.File } func (s *MatchHelperTestSuite) SetupTest() { @@ -449,3 +451,46 @@ func (s *MatchHelperTestSuite) match(source *dst.File, matchers []match.GoNode) return } + +func TestModuleMethod(t *testing.T) { + source := `package blog + +func Migrations() []string { + return []string{"function"} +} + +func (m *Module) Other() []string { + return []string{"other"} +} + +func (m *Module) Migrations() []schema.Migration { + return []schema.Migration{&migrations.Posts{}} +} +` + file, err := decorator.Parse(source) + require.NoError(t, err) + + find := func(matchers []match.GoNode) (matched dst.Node) { + var current int + dstutil.Apply(file, func(cursor *dstutil.Cursor) bool { + if current < len(matchers) && matchers[current].MatchCursor(cursor) { + current++ + if current == len(matchers) { + matched = cursor.Node() + } + } + + return matched == nil + }, nil) + + return + } + + lit, ok := find(ModuleMethod("Migrations")).(*dst.CompositeLit) + require.True(t, ok) + require.Len(t, lit.Elts, 1) + _, isUnary := lit.Elts[0].(*dst.UnaryExpr) + assert.True(t, isUnary) + + assert.Nil(t, find(ModuleMethod("Seeders"))) +} diff --git a/packages/modify/utils.go b/packages/modify/utils.go index d36ac4658..abce14729 100644 --- a/packages/modify/utils.go +++ b/packages/modify/utils.go @@ -1,15 +1,23 @@ package modify import ( + "cmp" + "go/ast" + "go/parser" + "go/token" "path/filepath" "slices" + "strconv" "strings" "github.com/dave/dst" "github.com/dave/dst/decorator" + "github.com/dave/dst/dstutil" + "github.com/goravel/framework/contracts/packages/modify" "github.com/goravel/framework/errors" "github.com/goravel/framework/packages/match" + "github.com/goravel/framework/support/env" supportfile "github.com/goravel/framework/support/file" "github.com/goravel/framework/support/path" ) @@ -158,6 +166,57 @@ func AddModule(pkg, module string) error { return handler.AddItem(pkg, module) } +// moduleMethods are the module capabilities AddToModule can edit: method -> slice type. +var moduleMethods = map[string]struct{ typeName, typeImportPath string }{ + "Commands": {"console.Command", "github.com/goravel/framework/contracts/console"}, + "Jobs": {"queue.Job", "github.com/goravel/framework/contracts/queue"}, + "Migrations": {"schema.Migration", "github.com/goravel/framework/contracts/database/schema"}, + "Seeders": {"seeder.Seeder", "github.com/goravel/framework/contracts/database/seeder"}, + "ServiceProviders": {"foundation.ServiceProvider", "github.com/goravel/framework/contracts/foundation"}, +} + +// AddToModule adds item to the slice returned by method of the module struct in dir +// (relative to the project root), appending the method when the struct lacks it. +func AddToModule(dir, method, pkg, item string) error { + spec, ok := moduleMethods[method] + if !ok { + return errors.PackageModuleMethodUnsupported.Args(method) + } + + target, receiver, exists, err := findModuleMethod(dir, method) + if err != nil { + return err + } + + if !exists { + stub := "\nfunc " + receiver + " " + method + "() []" + spec.typeName + " {\n\treturn []" + spec.typeName + "{}\n}\n" + if err := supportfile.PutContent(target, stub, supportfile.WithAppend()); err != nil { + return err + } + if err := addImportsToFile(target, spec.typeImportPath); err != nil { + return err + } + } + + if err := addImportsToFile(target, pkg); err != nil { + return err + } + + // The application's Go module is what pkg has before the module directory. + main := env.MainPath() + if _, importPath, found := strings.Cut(pkg, " "); found { + pkg = importPath + } + if before, _, found := strings.Cut(pkg, "/"+strings.Trim(filepath.ToSlash(dir), "/")+"/"); found { + main = before + } + + return GoFile(target). + Find(match.ModuleMethod(method)).Modify(Register(item)). + Find(match.Imports()).Modify(groupImports(main)). + Apply() +} + // AddProvider adds service provider to the foundation.Setup() chain in the Boot function. func AddProvider(pkg, provider string) error { config := withSliceConfig{ @@ -527,6 +586,125 @@ func WrapNewline[T dst.Node](node T) T { return node } +// groupImports sorts the matched import block into standard library, third-party +// and application (inside the Go module main) groups, separated by an empty line. +func groupImports(main string) modify.Action { + return func(cursor *dstutil.Cursor) { + groupImportSpecs(cursor.Node().(*dst.GenDecl), main) + } +} + +func groupImportSpecs(node *dst.GenDecl, main string) { + importPath := func(spec dst.Spec) string { + value, _ := strconv.Unquote(spec.(*dst.ImportSpec).Path.Value) + + return value + } + group := func(spec dst.Spec) int { + value := importPath(spec) + switch { + case value == main || strings.HasPrefix(value, main+"/"): + return 2 + case isThirdParty(value): + return 1 + default: + return 0 + } + } + + slices.SortStableFunc(node.Specs, func(a, b dst.Spec) int { + return cmp.Or(cmp.Compare(group(a), group(b)), cmp.Compare(importPath(a), importPath(b))) + }) + for i, spec := range node.Specs { + decs := spec.Decorations() + decs.Before, decs.After = dst.NewLine, dst.NewLine + if i > 0 && group(spec) != group(node.Specs[i-1]) { + decs.Before = dst.EmptyLine + } + } +} + +// findModuleMethod returns the file declaring method on the module struct of dir. When +// no file does, it returns the file declaring Name() and the receiver to declare it with. +func findModuleMethod(dir, method string) (target, receiver string, exists bool, err error) { + root := path.Base(filepath.FromSlash(dir)) + files, err := filepath.Glob(filepath.Join(root, "*.go")) + if err != nil { + return "", "", false, err + } + + // module.go first: it wins when several types have a Name() method. + slices.SortStableFunc(files, func(a, b string) int { + return cmp.Compare(moduleFileRank(a), moduleFileRank(b)) + }) + + type moduleFunc struct{ file, name, typeName, receiver string } + var methods []moduleFunc + for _, file := range files { + if strings.HasSuffix(file, "_test.go") { + continue + } + + parsed, err := parser.ParseFile(token.NewFileSet(), file, nil, parser.SkipObjectResolution) + if err != nil { + return "", "", false, err + } + for _, decl := range parsed.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Recv == nil || len(fn.Recv.List) != 1 { + continue + } + + typeName, text := receiverOf(fn.Recv.List[0]) + if typeName != "" { + methods = append(methods, moduleFunc{file: file, name: fn.Name.Name, typeName: typeName, receiver: text}) + } + } + } + + nameIndex := slices.IndexFunc(methods, func(m moduleFunc) bool { return m.name == "Name" }) + if nameIndex < 0 { + return "", "", false, errors.PackageModuleStructNotFound.Args(dir) + } + + module := methods[nameIndex] + for _, m := range methods { + if m.name == method && m.typeName == module.typeName { + return m.file, m.receiver, true, nil + } + } + + return module.file, module.receiver, false, nil +} + +func moduleFileRank(file string) int { + if filepath.Base(file) == "module.go" { + return 0 + } + + return 1 +} + +// receiverOf returns the type name of a method receiver and its source, e.g. "(m *Module)". +func receiverOf(field *ast.Field) (typeName, text string) { + expr, pointer := field.Type, "" + if star, ok := expr.(*ast.StarExpr); ok { + expr, pointer = star.X, "*" + } + + ident, ok := expr.(*ast.Ident) + if !ok { + return "", "" + } + + name := "" + if len(field.Names) == 1 { + name = field.Names[0].Name + " " + } + + return ident.Name, "(" + name + pointer + ident.Name + ")" +} + // addImportsToFile adds the specified import to the file, creating the import block if needed. func addImportsToFile(filePath, pkg string) error { importMatchers := match.Imports() diff --git a/packages/modify/utils_test.go b/packages/modify/utils_test.go index aeb20a4cc..562461dd9 100644 --- a/packages/modify/utils_test.go +++ b/packages/modify/utils_test.go @@ -5030,3 +5030,116 @@ func Modules() []modular.Module { } `, content) } + +func TestAddToModule(t *testing.T) { + dir := "modules/blog" + moduleFile := filepath.Join(dir, "module.go") + defer func() { + assert.NoError(t, supportfile.Remove("modules")) + }() + + assert.ErrorIs(t, AddToModule(dir, "Migrations", "goravel/modules/blog/database/migrations", "&migrations.Posts{}"), errors.PackageModuleStructNotFound) + + assert.NoError(t, supportfile.PutContent(moduleFile, `package blog + +import ( + "embed" + "io/fs" + + "goravel/modules/blog/routes" +) + +type Module struct{} + +func (m *Module) Name() string { + return "blog" +} +`)) + + assert.ErrorIs(t, AddToModule(dir, "Rules", "goravel/modules/blog/rules", "&rules.Slug{}"), errors.PackageModuleMethodUnsupported) + + // The method is appended, then extended; a duplicate item is a no-op. + assert.NoError(t, AddToModule(dir, "Migrations", "goravel/modules/blog/database/migrations", "&migrations.Posts{}")) + assert.NoError(t, AddToModule(dir, "Migrations", "goravel/modules/blog/database/migrations", "&migrations.Comments{}")) + assert.NoError(t, AddToModule(dir, "Migrations", "goravel/modules/blog/database/migrations", "&migrations.Posts{}")) + assert.NoError(t, AddToModule(dir, "ServiceProviders", "goravel/modules/blog/providers", "&providers.BlogServiceProvider{}")) + + content, err := supportfile.GetContent(moduleFile) + assert.NoError(t, err) + assert.Equal(t, `package blog + +import ( + "embed" + "io/fs" + + "github.com/goravel/framework/contracts/database/schema" + "github.com/goravel/framework/contracts/foundation" + + "goravel/modules/blog/database/migrations" + "goravel/modules/blog/providers" + "goravel/modules/blog/routes" +) + +type Module struct{} + +func (m *Module) Name() string { + return "blog" +} + +func (m *Module) Migrations() []schema.Migration { + return []schema.Migration{ + &migrations.Posts{}, + &migrations.Comments{}, + } +} + +func (m *Module) ServiceProviders() []foundation.ServiceProvider { + return []foundation.ServiceProvider{ + &providers.BlogServiceProvider{}, + } +} +`, content) + + // A method declared in another file of the package is edited there. + commandsFile := filepath.Join(dir, "commands.go") + assert.NoError(t, supportfile.PutContent(commandsFile, `package blog + +import ( + "github.com/goravel/framework/contracts/console" +) + +func (m *Module) Commands() []console.Command { + return []console.Command{} +} +`)) + assert.NoError(t, AddToModule(dir, "Commands", "goravel/modules/blog/console/commands", "&commands.Publish{}")) + + content, err = supportfile.GetContent(commandsFile) + assert.NoError(t, err) + assert.Equal(t, `package blog + +import ( + "github.com/goravel/framework/contracts/console" + + "goravel/modules/blog/console/commands" +) + +func (m *Module) Commands() []console.Command { + return []console.Command{ + &commands.Publish{}, + } +} +`, content) + assert.False(t, supportfile.Contain(moduleFile, "Commands()")) + + // A method that does not return a slice literal cannot be edited. + assert.NoError(t, supportfile.PutContent(filepath.Join(dir, "jobs.go"), `package blog + +import "github.com/goravel/framework/contracts/queue" + +func (m *Module) Jobs() []queue.Job { + return nil +} +`)) + assert.Error(t, AddToModule(dir, "Jobs", "goravel/modules/blog/jobs", "&jobs.Notify{}")) +} diff --git a/packages/modify/with_slice_actions.go b/packages/modify/with_slice_actions.go index eec76b90e..02688e1aa 100644 --- a/packages/modify/with_slice_actions.go +++ b/packages/modify/with_slice_actions.go @@ -928,21 +928,10 @@ func containsFoundationSetup(node dst.Node) bool { return foundSetup } +// createImport adds an empty import block as the first declaration of the file. func createImport(node dst.Node) error { - importDecl := &dst.GenDecl{ - Tok: token.IMPORT, - } - f := node.(*dst.File) - - newDecls := make([]dst.Decl, 0, len(f.Decls)+1) - newDecls = append(newDecls, f.Decls[0], importDecl) // package and import - - if len(f.Decls) > 1 { - newDecls = append(newDecls, f.Decls[1:]...) // others - } - - f.Decls = newDecls + f.Decls = append([]dst.Decl{&dst.GenDecl{Tok: token.IMPORT}}, f.Decls...) return nil } From d105579ca923f666cce4cb1f3a7368a0d9297178 Mon Sep 17 00:00:00 2001 From: Saad Date: Wed, 11 Nov 2026 20:41:56 +0600 Subject: [PATCH 17/29] feat(console): add the --module option to the make commands ./artisan make:controller PostController --module=blog writes modules/blog/http/controllers/post_controller.go, with the package and import path of that directory. NewMake reads the option, finds the module in the registry (as given, then in kebab case) and moves the generator's directory under the module's, dropping the application directory: app/http/controllers becomes /http/controllers, database/factories stays database/factories. An unknown module fails with ModularModuleNotFound and a third-party one with ModularModuleNotLocal. support/console cannot reach the container, so Build() records the registry with modular.SetDefault and NewMake reads modular.Default(). The flag is on the generators that only write a file: agent, tool, policy, channel, command-hook, factory, model, observer, event, listener, controller, middleware, request, mail, notification, filter and rule. Rules, filters and command hooks have no module capability, they still register in bootstrap/. Also fixes the Ordered comment: the default order is 0. --- ai/console/agent_make_command.go | 1 + ai/console/agent_make_command_test.go | 7 +- ai/console/tool_make_command.go | 1 + ai/console/tool_make_command_test.go | 7 +- auth/console/policy_make_command.go | 1 + auth/console/policy_make_command_test.go | 4 + broadcasting/console/channel_make_command.go | 1 + .../console/channel_make_command_test.go | 2 + console/console/make_command_hook.go | 1 + console/console/make_command_hook_test.go | 8 ++ console/console/make_command_test.go | 5 ++ contracts/modular/modular.go | 2 +- database/console/factory_make_command.go | 1 + database/console/factory_make_command_test.go | 4 + .../migration/migrate_make_command_test.go | 9 +++ database/console/model_make_command.go | 1 + database/console/model_make_command_test.go | 7 ++ database/console/observer_make_command.go | 1 + .../console/observer_make_command_test.go | 4 + database/console/seeder_make_command_test.go | 6 ++ errors/list.go | 1 + event/console/event_make_command.go | 1 + event/console/event_make_command_test.go | 6 ++ event/console/listener_make_command.go | 1 + event/console/listener_make_command_test.go | 4 + foundation/application.go | 1 + .../console/provider_make_command_test.go | 5 ++ foundation/console/test_make_command_test.go | 4 + http/console/controller_make_command.go | 1 + http/console/controller_make_command_test.go | 39 ++++++++++ http/console/middleware_make_command.go | 1 + http/console/middleware_make_command_test.go | 4 + http/console/request_make_command.go | 1 + http/console/request_make_command_test.go | 4 + mail/console/mail_make_command.go | 1 + mail/console/mail_make_command_test.go | 4 + modular/prefix_fs_test.go | 9 ++- modular/registry.go | 32 ++++++++ notification/console/make_command.go | 1 + notification/console/make_command_test.go | 7 +- queue/console/job_make_command_test.go | 8 ++ support/console/console.go | 75 +++++++++++++++++- support/console/console_test.go | 76 +++++++++++++++++++ validation/console/filter_make_command.go | 1 + .../console/filter_make_command_test.go | 9 +++ validation/console/rule_make_command.go | 1 + validation/console/rule_make_command_test.go | 8 ++ 47 files changed, 368 insertions(+), 10 deletions(-) diff --git a/ai/console/agent_make_command.go b/ai/console/agent_make_command.go index 08e8614a3..f15f42549 100644 --- a/ai/console/agent_make_command.go +++ b/ai/console/agent_make_command.go @@ -33,6 +33,7 @@ func (r *AgentMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the agent even if it already exists", }, + supportconsole.ModuleFlag(), }, } } diff --git a/ai/console/agent_make_command_test.go b/ai/console/agent_make_command_test.go index 40f5333d7..5309b6f12 100644 --- a/ai/console/agent_make_command_test.go +++ b/ai/console/agent_make_command_test.go @@ -28,7 +28,7 @@ func TestAgentMakeCommand(t *testing.T) { extend := agentMakeCommand.Extend() assert.Equal(t, "make", extend.Category) - if assert.Len(t, extend.Flags, 1) { + if assert.Len(t, extend.Flags, 2) { flag, ok := extend.Flags[0].(*command.BoolFlag) assert.True(t, ok) if ok { @@ -44,17 +44,20 @@ func TestAgentMakeCommand(t *testing.T) { assert.NoError(t, agentMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"UserAgent"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Agent created successfully").Once() assert.NoError(t, agentMakeCommand.Handle(mockContext)) assert.True(t, file.Exists("app/ai/agents/user_agent.go")) mockContext.EXPECT().Arguments().Return([]string{"UserAgent"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the agent already exists. Use the --force or -f flag to overwrite").Once() assert.NoError(t, agentMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"user/SupportAgent"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Agent created successfully").Once() assert.NoError(t, agentMakeCommand.Handle(mockContext)) @@ -67,6 +70,7 @@ func TestAgentMakeCommand(t *testing.T) { support.Config.Paths.Agents = "custom/agents" mockContext.EXPECT().Arguments().Return([]string{"BillingAgent"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Agent created successfully").Once() assert.NoError(t, agentMakeCommand.Handle(mockContext)) @@ -81,6 +85,7 @@ func TestAgentMakeCommand_MultipleNames(t *testing.T) { }() mockContext.EXPECT().Arguments().Return([]string{"SupportAgent", "BillingAgent"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Agent created successfully").Times(2) assert.NoError(t, agentMakeCommand.Handle(mockContext)) diff --git a/ai/console/tool_make_command.go b/ai/console/tool_make_command.go index bc55c5d82..70595bf66 100644 --- a/ai/console/tool_make_command.go +++ b/ai/console/tool_make_command.go @@ -34,6 +34,7 @@ func (r *ToolMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the tool even if it already exists", }, + supportconsole.ModuleFlag(), }, } } diff --git a/ai/console/tool_make_command_test.go b/ai/console/tool_make_command_test.go index a8bd13a17..55a363c9e 100644 --- a/ai/console/tool_make_command_test.go +++ b/ai/console/tool_make_command_test.go @@ -28,7 +28,7 @@ func TestToolMakeCommand(t *testing.T) { extend := toolMakeCommand.Extend() assert.Equal(t, "make", extend.Category) - if assert.Len(t, extend.Flags, 1) { + if assert.Len(t, extend.Flags, 2) { flag, ok := extend.Flags[0].(*command.BoolFlag) assert.True(t, ok) if ok { @@ -44,17 +44,20 @@ func TestToolMakeCommand(t *testing.T) { assert.NoError(t, toolMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"WeatherTool"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Tool created successfully").Once() assert.NoError(t, toolMakeCommand.Handle(mockContext)) assert.True(t, file.Exists("app/ai/tools/weather_tool.go")) mockContext.EXPECT().Arguments().Return([]string{"WeatherTool"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the tool already exists. Use the --force or -f flag to overwrite").Once() assert.NoError(t, toolMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"user/WeatherTool"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Tool created successfully").Once() assert.NoError(t, toolMakeCommand.Handle(mockContext)) @@ -71,6 +74,7 @@ func TestToolMakeCommand(t *testing.T) { support.Config.Paths.Tools = "custom/tools" mockContext.EXPECT().Arguments().Return([]string{"CurrencyTool"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Tool created successfully").Once() assert.NoError(t, toolMakeCommand.Handle(mockContext)) @@ -85,6 +89,7 @@ func TestToolMakeCommand_MultipleNames(t *testing.T) { }() mockContext.EXPECT().Arguments().Return([]string{"WeatherTool", "CurrencyTool"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Tool created successfully").Times(2) assert.NoError(t, toolMakeCommand.Handle(mockContext)) diff --git a/auth/console/policy_make_command.go b/auth/console/policy_make_command.go index 7a07ae5d6..e281fae57 100644 --- a/auth/console/policy_make_command.go +++ b/auth/console/policy_make_command.go @@ -37,6 +37,7 @@ func (r *PolicyMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the policy even if it already exists", }, + supportconsole.ModuleFlag(), }, } } diff --git a/auth/console/policy_make_command_test.go b/auth/console/policy_make_command_test.go index 8b2813b29..a01c70e7c 100644 --- a/auth/console/policy_make_command_test.go +++ b/auth/console/policy_make_command_test.go @@ -20,17 +20,20 @@ func TestPolicyMakeCommand(t *testing.T) { assert.Nil(t, policyMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"UserPolicy"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Policy created successfully").Once() assert.Nil(t, policyMakeCommand.Handle(mockContext)) assert.True(t, file.Exists("app/policies/user_policy.go")) mockContext.EXPECT().Arguments().Return([]string{"UserPolicy"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the policy already exists. Use the --force or -f flag to overwrite").Once() assert.Nil(t, policyMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"User/AuthPolicy"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Policy created successfully").Once() assert.Nil(t, policyMakeCommand.Handle(mockContext)) @@ -50,6 +53,7 @@ func TestPolicyMakeCommand_MultipleNames(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"PostPolicy", "CommentPolicy"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Policy created successfully").Times(2) assert.Nil(t, policyMakeCommand.Handle(mockContext)) diff --git a/broadcasting/console/channel_make_command.go b/broadcasting/console/channel_make_command.go index 882569090..fa2df69cb 100644 --- a/broadcasting/console/channel_make_command.go +++ b/broadcasting/console/channel_make_command.go @@ -33,6 +33,7 @@ func (r *ChannelMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the channel even if it already exists", }, + supportconsole.ModuleFlag(), }, } } diff --git a/broadcasting/console/channel_make_command_test.go b/broadcasting/console/channel_make_command_test.go index 2867a3b0b..9253b10b5 100644 --- a/broadcasting/console/channel_make_command_test.go +++ b/broadcasting/console/channel_make_command_test.go @@ -30,6 +30,7 @@ func TestChannelMakeCommand(t *testing.T) { t.Run("success", func(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"TestChannel"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Channel created successfully").Once() assert.Nil(t, cmd.Handle(mockContext)) @@ -51,6 +52,7 @@ func TestChannelMakeCommand_MultipleNames(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"OrderChannel", "UserChannel"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Channel created successfully").Times(2) assert.Nil(t, cmd.Handle(mockContext)) diff --git a/console/console/make_command_hook.go b/console/console/make_command_hook.go index e9e161a5a..93d65721e 100644 --- a/console/console/make_command_hook.go +++ b/console/console/make_command_hook.go @@ -48,6 +48,7 @@ func (r *MakeCommandHook) Extend() command.Extend { Aliases: []string{"c"}, Usage: "The command signature or '*' pattern the hook runs for, default is '*'", }, + supportconsole.ModuleFlag(), }, } } diff --git a/console/console/make_command_hook_test.go b/console/console/make_command_hook_test.go index 7df268e44..79921312a 100644 --- a/console/console/make_command_hook_test.go +++ b/console/console/make_command_hook_test.go @@ -118,6 +118,7 @@ func TestMakeCommandHook(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Option("command").Return("").Once() mockContext.EXPECT().Arguments().Return([]string{"AuditLog"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the command hook already exists. Use the --force or -f flag to overwrite").Once() @@ -130,6 +131,7 @@ func TestMakeCommandHook(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Option("command").Return("*").Once() mockContext.EXPECT().Arguments().Return([]string{"AuditLog"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(true).Once() mockContext.EXPECT().Success("Command hook created successfully").Once() mockContext.EXPECT().Warning(mock.Anything).Once() @@ -145,6 +147,7 @@ func TestMakeCommandHook(t *testing.T) { mockContext.EXPECT().Option("command").Return("").Once() mockContext.EXPECT().Arguments().Return([]string{"AuditLog"}).Once() mockContext.EXPECT().Ask("Enter the command signature the hook runs for", mock.Anything).Return("*", nil).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Command hook created successfully").Once() mockContext.EXPECT().Warning("Command hook created but not registered, add it to WithCommandHooks in bootstrap/app.go manually").Once() @@ -168,6 +171,7 @@ func TestMakeCommandHook(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Option("command").Return("migrate").Once() mockContext.EXPECT().Arguments().Return([]string{"AuditLog"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Command hook created successfully").Once() mockContext.EXPECT().Success("Command hook registered successfully").Once() @@ -191,6 +195,7 @@ func TestMakeCommandHook(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Option("command").Return("migrate").Once() mockContext.EXPECT().Arguments().Return([]string{"AuditLog"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Command hook created successfully").Once() mockContext.EXPECT().Success("Command hook registered successfully").Once() @@ -217,6 +222,7 @@ func TestMakeCommandHook(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Option("command").Return("migrate").Once() mockContext.EXPECT().Arguments().Return([]string{"AuditLog"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Command hook created successfully").Once() mockContext.EXPECT().Success("Command hook registered successfully").Once() @@ -242,6 +248,7 @@ func TestMakeCommandHook(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Option("command").Return("migrate").Once() mockContext.EXPECT().Arguments().Return([]string{"AuditLog"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Command hook created successfully").Once() mockContext.EXPECT().Error(mock.MatchedBy(func(message string) bool { @@ -259,6 +266,7 @@ func TestMakeCommandHook(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Option("command").Return("migrate").Once() mockContext.EXPECT().Arguments().Return([]string{"AuditLog", "SlowQuery"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Command hook created successfully").Times(2) mockContext.EXPECT().Success("Command hook registered successfully").Times(2) diff --git a/console/console/make_command_test.go b/console/console/make_command_test.go index 5b0e690d2..2fb6a5c34 100644 --- a/console/console/make_command_test.go +++ b/console/console/make_command_test.go @@ -70,6 +70,7 @@ func (kernel Kernel) Schedule() []schedule.Event { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"CleanCache"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Console command created successfully").Once() mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { @@ -85,6 +86,7 @@ func (kernel Kernel) Schedule() []schedule.Event { t.Run("command already exists", func(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"CleanCache"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the command already exists. Use the --force or -f flag to overwrite").Once() assert.Nil(t, makeCommand.Handle(mockContext)) @@ -95,6 +97,7 @@ func (kernel Kernel) Schedule() []schedule.Event { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"Goravel/CleanCache"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Console command created successfully").Once() mockContext.EXPECT().Success("Console command registered successfully").Once() @@ -123,6 +126,7 @@ func TestMakeCommand_MultipleNames(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"Goravel/CleanCache", "Bar/SyncUsers"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Console command created successfully").Times(2) mockContext.EXPECT().Success("Console command registered successfully").Times(2) @@ -166,6 +170,7 @@ func Boot() contractsfoundation.Application { // Create mock context mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"CleanCache"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Console command created successfully").Once() mockContext.EXPECT().Success("Console command registered successfully").Once() diff --git a/contracts/modular/modular.go b/contracts/modular/modular.go index 37e110d64..f337b60ca 100644 --- a/contracts/modular/modular.go +++ b/contracts/modular/modular.go @@ -29,7 +29,7 @@ type Located interface { } // Ordered sets the module order: lower first, negative last, ties by registration. -// The default is 1. +// The default is 0. type Ordered interface { Order() int } diff --git a/database/console/factory_make_command.go b/database/console/factory_make_command.go index 36215950c..e918e457f 100644 --- a/database/console/factory_make_command.go +++ b/database/console/factory_make_command.go @@ -37,6 +37,7 @@ func (r *FactoryMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the factory even if it already exists", }, + supportconsole.ModuleFlag(), }, } } diff --git a/database/console/factory_make_command_test.go b/database/console/factory_make_command_test.go index 924a812d0..e31acb722 100644 --- a/database/console/factory_make_command_test.go +++ b/database/console/factory_make_command_test.go @@ -20,6 +20,7 @@ func TestFactoryMakeCommand(t *testing.T) { assert.NoError(t, factoryMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"UserFactory"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Factory created successfully").Once() assert.NoError(t, factoryMakeCommand.Handle(mockContext)) @@ -28,12 +29,14 @@ func TestFactoryMakeCommand(t *testing.T) { assert.True(t, file.Contain("database/factories/user_factory.go", "type UserFactory struct")) mockContext.EXPECT().Arguments().Return([]string{"UserFactory"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the factory already exists. Use the --force or -f flag to overwrite").Once() assert.NoError(t, factoryMakeCommand.Handle(mockContext)) assert.NoError(t, file.Remove("database")) mockContext.EXPECT().Arguments().Return([]string{"subdir/DemoFactory"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Factory created successfully").Once() assert.NoError(t, factoryMakeCommand.Handle(mockContext)) @@ -48,6 +51,7 @@ func TestFactoryMakeCommand_MultipleNames(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"UserFactory", "PostFactory"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Factory created successfully").Times(2) assert.NoError(t, factoryMakeCommand.Handle(mockContext)) diff --git a/database/console/migration/migrate_make_command_test.go b/database/console/migration/migrate_make_command_test.go index 2df1933b2..666e188aa 100644 --- a/database/console/migration/migrate_make_command_test.go +++ b/database/console/migration/migrate_make_command_test.go @@ -36,6 +36,7 @@ func TestMigrateMakeCommand(t *testing.T) { setup: func() { mockContext.EXPECT().Arguments().Return([]string{""}).Once() mockContext.EXPECT().Ask("Enter the migration name", mock.Anything).Return("create_users_table", nil).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Option("model").Return("").Once() mockMigrator.EXPECT().Create("create_users_table", "").Return("20240915060148_create_users_table", nil).Once() @@ -50,6 +51,7 @@ func TestMigrateMakeCommand(t *testing.T) { name: "Happy path - name is not empty", setup: func() { mockContext.EXPECT().Arguments().Return([]string{"create_users_table"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Option("model").Return("").Once() mockMigrator.EXPECT().Create("create_users_table", "").Return("20240915060148_create_users_table", nil).Once() @@ -65,6 +67,7 @@ func TestMigrateMakeCommand(t *testing.T) { setup: func() { mockContext.EXPECT().Option("model").Return("Product").Once() mockContext.EXPECT().Arguments().Return([]string{"create_products_table"}).Times(2) + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockMigrator.EXPECT().Create("create_products_table", "Product").Return("20240915060148_create_products_table", nil).Once() mockContext.EXPECT().Success("Created Migration: create_products_table").Once() @@ -87,6 +90,7 @@ func TestMigrateMakeCommand(t *testing.T) { name: "Sad path - failed to create", setup: func() { mockContext.EXPECT().Arguments().Return([]string{"create_users_table"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Option("model").Return("").Once() mockMigrator.EXPECT().Create("create_users_table", "").Return("", assert.AnError).Once() @@ -98,6 +102,7 @@ func TestMigrateMakeCommand(t *testing.T) { setup: func() { mockContext.EXPECT().Option("model").Return("NonExistentModel").Once() mockContext.EXPECT().Arguments().Return([]string{"create_products_table"}).Times(2) + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockMigrator.EXPECT().Create("create_products_table", "NonExistentModel").Return("", errors.SchemaModelNotFound.Args("NonExistentModel")).Once() mockContext.EXPECT().Error(errors.MigrationCreateFailed.Args(errors.SchemaModelNotFound.Args("NonExistentModel")).Error()).Once() @@ -107,6 +112,7 @@ func TestMigrateMakeCommand(t *testing.T) { name: "Register success", setup: func() { mockContext.EXPECT().Arguments().Return([]string{"create_users_table"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Option("model").Return("").Once() mockMigrator.EXPECT().Create("create_users_table", "").Return("20240915060148_create_users_table", nil).Once() @@ -188,6 +194,7 @@ func Boot() contractsfoundation.Application { mockApp := mocksfoundation.NewApplication(t) mockContext.EXPECT().Arguments().Return([]string{"create_posts_table"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Option("model").Return("").Once() mockMigrator.EXPECT().Create("create_posts_table", "").Return("20240915060148_create_posts_table", nil).Once() @@ -224,6 +231,7 @@ func Boot() { mockApp := mocksfoundation.NewApplication(t) mockContext.EXPECT().Arguments().Return([]string{"create_comments_table"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Option("model").Return("").Once() mockMigrator.EXPECT().Create("create_comments_table", "").Return("20240915060149_create_comments_table", nil).Once() @@ -265,6 +273,7 @@ func (kernel Kernel) Migrations() []schema.Migration { mockContext.EXPECT().Option("model").Return("").Once() mockContext.EXPECT().Arguments().Return([]string{"create_users_table", "create_posts_table"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockMigrator.EXPECT().Create("create_users_table", "").Return("20240915060148_create_users_table", nil).Once() mockMigrator.EXPECT().Create("create_posts_table", "").Return("20240915060149_create_posts_table", nil).Once() diff --git a/database/console/model_make_command.go b/database/console/model_make_command.go index 79fc0e63e..d24d48cde 100644 --- a/database/console/model_make_command.go +++ b/database/console/model_make_command.go @@ -67,6 +67,7 @@ func (r *ModelMakeCommand) Extend() command.Extend { Aliases: []string{"t"}, Usage: "Create the model from existing table schema", }, + supportconsole.ModuleFlag(), }, } } diff --git a/database/console/model_make_command_test.go b/database/console/model_make_command_test.go index bbb1a2ddb..5732100b5 100644 --- a/database/console/model_make_command_test.go +++ b/database/console/model_make_command_test.go @@ -33,6 +33,7 @@ func TestModelMakeCommand(t *testing.T) { // Test: Create model successfully mockContext.EXPECT().Arguments().Return([]string{"User"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Option("table").Return("").Once() mockContext.EXPECT().Success("Model created successfully").Once() @@ -57,12 +58,14 @@ type User struct { // Test: Model already exists mockContext.EXPECT().Option("table").Return("").Once() mockContext.EXPECT().Arguments().Return([]string{"User"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the model already exists. Use the --force or -f flag to overwrite").Once() assert.Nil(t, modelMakeCommand.Handle(mockContext)) // Test: Create model in subdirectory mockContext.EXPECT().Arguments().Return([]string{"User/Phone"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Option("table").Return("").Once() mockContext.EXPECT().Success("Model created successfully").Once() @@ -89,6 +92,7 @@ type Phone struct { // Test: Create model from table schema mockContext.EXPECT().Option("table").Return("products").Once() mockContext.EXPECT().Arguments().Return([]string{"Product"}).Times(2) + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() // Table exists @@ -556,6 +560,7 @@ func TestModelMakeCommand_MultipleNames(t *testing.T) { mockContext.EXPECT().Option("table").Return("").Once() mockContext.EXPECT().Arguments().Return([]string{"User", "Post"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Model created successfully").Times(2) assert.Nil(t, modelMakeCommand.Handle(mockContext)) @@ -598,12 +603,14 @@ func TestModelMakeCommand_MultipleNamesPartialFailure(t *testing.T) { mockContext.EXPECT().Option("table").Return("").Once() mockContext.EXPECT().Arguments().Return([]string{"User"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Model created successfully").Once() assert.Nil(t, modelMakeCommand.Handle(mockContext)) mockContext.EXPECT().Option("table").Return("").Once() mockContext.EXPECT().Arguments().Return([]string{"User", "Post"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Error("the model already exists. Use the --force or -f flag to overwrite").Once() mockContext.EXPECT().Success("Model created successfully").Once() diff --git a/database/console/observer_make_command.go b/database/console/observer_make_command.go index caa6f753e..9f9cc00ea 100644 --- a/database/console/observer_make_command.go +++ b/database/console/observer_make_command.go @@ -37,6 +37,7 @@ func (r *ObserverMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the observer even if it already exists", }, + supportconsole.ModuleFlag(), }, } } diff --git a/database/console/observer_make_command_test.go b/database/console/observer_make_command_test.go index d7a3b185f..a58e2f0ec 100644 --- a/database/console/observer_make_command_test.go +++ b/database/console/observer_make_command_test.go @@ -21,17 +21,20 @@ func TestObserverMakeCommand(t *testing.T) { assert.False(t, file.Exists("app/observers/user_observer.go")) mockContext.EXPECT().Arguments().Return([]string{"UserObserver"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Observer created successfully").Once() assert.Nil(t, observerMakeCommand.Handle(mockContext)) assert.True(t, file.Exists("app/observers/user_observer.go")) mockContext.EXPECT().Arguments().Return([]string{"UserObserver"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the observer already exists. Use the --force or -f flag to overwrite").Once() assert.Nil(t, observerMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"User/PhoneObserver"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Observer created successfully").Once() assert.Nil(t, observerMakeCommand.Handle(mockContext)) @@ -51,6 +54,7 @@ func TestObserverMakeCommand_MultipleNames(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"UserObserver", "PostObserver"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Observer created successfully").Times(2) assert.NoError(t, observerMakeCommand.Handle(mockContext)) diff --git a/database/console/seeder_make_command_test.go b/database/console/seeder_make_command_test.go index c5ae47244..d3ca0dfc7 100644 --- a/database/console/seeder_make_command_test.go +++ b/database/console/seeder_make_command_test.go @@ -42,6 +42,7 @@ func TestSeederMakeCommand(t *testing.T) { assert.Nil(t, seederMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"UserSeeder"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockApp.EXPECT().DatabasePath("kernel.go").Return("database/kernel.go").Once() mockContext.EXPECT().Success("Seeder created successfully").Once() @@ -55,12 +56,14 @@ func TestSeederMakeCommand(t *testing.T) { assert.True(t, file.Contain("database/kernel.go", "&seeders.UserSeeder{}")) mockContext.EXPECT().Arguments().Return([]string{"UserSeeder"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the seeder already exists. Use the --force or -f flag to overwrite").Once() assert.NoError(t, seederMakeCommand.Handle(mockContext)) assert.NoError(t, file.Remove("database")) mockContext.EXPECT().Arguments().Return([]string{"subdir/DemoSeeder"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Seeder created successfully").Once() mockApp.EXPECT().DatabasePath("kernel.go").Return("database/kernel.go").Once() @@ -108,6 +111,7 @@ func Boot() contractsfoundation.Application { mockApp = mocksfoundation.NewApplication(t) mockContext.EXPECT().Arguments().Return([]string{"UserSeeder"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Seeder created successfully").Once() mockContext.EXPECT().Success("Seeder registered successfully").Once() @@ -145,6 +149,7 @@ func Boot() { mockApp = mocksfoundation.NewApplication(t) mockContext.EXPECT().Arguments().Return([]string{"PostSeeder"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Seeder created successfully").Once() mockContext.EXPECT().Warning(mock.MatchedBy(func(msg string) bool { @@ -173,6 +178,7 @@ func TestSeederMakeCommand_MultipleNames(t *testing.T) { assert.NoError(t, file.PutContent("database/kernel.go", databaseKernel)) mockContext.EXPECT().Arguments().Return([]string{"UserSeeder", "PostSeeder"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockApp.EXPECT().DatabasePath("kernel.go").Return("database/kernel.go").Times(2) mockContext.EXPECT().Success("Seeder created successfully").Times(2) diff --git a/errors/list.go b/errors/list.go index f72906212..e50ed5202 100644 --- a/errors/list.go +++ b/errors/list.go @@ -227,6 +227,7 @@ var ( ModularModuleNameForbidden = New("module name %q is reserved, Go cannot import a package named %q").SetModule(ModuleModular) ModularModuleNameInvalid = New("module name %q is invalid, use letters, digits and dashes, starting with a letter").SetModule(ModuleModular) ModularModuleNotFound = New("module %s is not registered").SetModule(ModuleModular) + ModularModuleNotLocal = New("module %s belongs to another Go module, files can only be generated in the application's own modules").SetModule(ModuleModular) ModularModuleNotPublished = New("module %s (%s) is not published to %s, run `go generate` or `artisan modular:publish`").SetModule(ModuleModular) ModularModulePathInvalid = New("module %s: directory %q must be a relative path inside the project").SetModule(ModuleModular) ModularModulePathUnknown = New("module %s: cannot derive its directory from package %q, implement Path() to set it").SetModule(ModuleModular) diff --git a/event/console/event_make_command.go b/event/console/event_make_command.go index e267bae12..54186c21c 100644 --- a/event/console/event_make_command.go +++ b/event/console/event_make_command.go @@ -41,6 +41,7 @@ func (r *EventMakeCommand) Extend() command.Extend { Name: "now", Usage: "Generate an event that also implements broadcasting.ShouldBroadcastNow", }, + supportconsole.ModuleFlag(), }, } } diff --git a/event/console/event_make_command_test.go b/event/console/event_make_command_test.go index 0a79bbe35..3524fe85c 100644 --- a/event/console/event_make_command_test.go +++ b/event/console/event_make_command_test.go @@ -21,18 +21,21 @@ func TestEventMakeCommand(t *testing.T) { mockContext.EXPECT().Arguments().Return([]string{"GoravelEvent"}).Once() mockContext.EXPECT().OptionBool("broadcast").Return(false).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Event created successfully").Once() assert.Nil(t, eventMakeCommand.Handle(mockContext)) assert.True(t, file.Exists("app/events/goravel_event.go")) mockContext.EXPECT().Arguments().Return([]string{"GoravelEvent"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the event already exists. Use the --force or -f flag to overwrite").Once() assert.Nil(t, eventMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"Goravel/Event"}).Once() mockContext.EXPECT().OptionBool("broadcast").Return(false).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Event created successfully").Once() assert.Nil(t, eventMakeCommand.Handle(mockContext)) @@ -43,6 +46,7 @@ func TestEventMakeCommand(t *testing.T) { mockContext.EXPECT().Arguments().Return([]string{"GoravelBroadcastEvent"}).Once() mockContext.EXPECT().OptionBool("broadcast").Return(true).Once() mockContext.EXPECT().OptionBool("now").Return(false).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Event created successfully").Once() assert.Nil(t, eventMakeCommand.Handle(mockContext)) @@ -58,6 +62,7 @@ func TestEventMakeCommand(t *testing.T) { mockContext.EXPECT().Arguments().Return([]string{"GoravelBroadcastEventNow"}).Once() mockContext.EXPECT().OptionBool("broadcast").Return(true).Once() mockContext.EXPECT().OptionBool("now").Return(true).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Event created successfully").Once() assert.Nil(t, eventMakeCommand.Handle(mockContext)) @@ -84,6 +89,7 @@ func TestEventMakeCommand_MultipleNames(t *testing.T) { mockContext.EXPECT().Arguments().Return([]string{"OrderShipped", "OrderPaid"}).Once() mockContext.EXPECT().OptionBool("broadcast").Return(false).Times(2) + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Event created successfully").Times(2) assert.Nil(t, eventMakeCommand.Handle(mockContext)) diff --git a/event/console/listener_make_command.go b/event/console/listener_make_command.go index 465018c80..e6d26e300 100644 --- a/event/console/listener_make_command.go +++ b/event/console/listener_make_command.go @@ -34,6 +34,7 @@ func (r *ListenerMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the listener even if it already exists", }, + supportconsole.ModuleFlag(), }, } } diff --git a/event/console/listener_make_command_test.go b/event/console/listener_make_command_test.go index 1750657f8..9533ae157 100644 --- a/event/console/listener_make_command_test.go +++ b/event/console/listener_make_command_test.go @@ -20,17 +20,20 @@ func TestListenerMakeCommand(t *testing.T) { assert.Nil(t, listenerMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"GoravelListen"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Listener created successfully").Once() assert.Nil(t, listenerMakeCommand.Handle(mockContext)) assert.True(t, file.Exists("app/listeners/goravel_listen.go")) mockContext.EXPECT().Arguments().Return([]string{"GoravelListen"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the listener already exists. Use the --force or -f flag to overwrite").Once() assert.Nil(t, listenerMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"Goravel/Listen"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Listener created successfully").Once() assert.Nil(t, listenerMakeCommand.Handle(mockContext)) @@ -45,6 +48,7 @@ func TestListenerMakeCommand_MultipleNames(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"SendOrderEmail", "SendReceiptEmail"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Listener created successfully").Times(2) assert.Nil(t, listenerMakeCommand.Handle(mockContext)) diff --git a/foundation/application.go b/foundation/application.go index 646c9d524..a736b6a68 100644 --- a/foundation/application.go +++ b/foundation/application.go @@ -731,6 +731,7 @@ func (r *Application) configureModules() { // Make caches instances; drop the previous registry on a rebuild. r.Fresh(binding.Modular) r.Instance(binding.Modular, registry) + modular.SetDefault(registry) r.modules = registry.All() } diff --git a/foundation/console/provider_make_command_test.go b/foundation/console/provider_make_command_test.go index 96307ffc2..40a91bd82 100644 --- a/foundation/console/provider_make_command_test.go +++ b/foundation/console/provider_make_command_test.go @@ -86,6 +86,7 @@ func (s *ProviderMakeCommandTestSuite) TestHandle() { // Test successful creation mockContext.EXPECT().Arguments().Return([]string{"UserServiceProvider"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Provider created successfully").Once() s.NoError(cmd.Handle(mockContext)) @@ -93,12 +94,14 @@ func (s *ProviderMakeCommandTestSuite) TestHandle() { // Test file already exists without force mockContext.EXPECT().Arguments().Return([]string{"UserServiceProvider"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the provider already exists. Use the --force or -f flag to overwrite").Once() s.Nil(cmd.Handle(mockContext)) // Test file already exists with force mockContext.EXPECT().Arguments().Return([]string{"UserServiceProvider"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(true).Once() mockContext.EXPECT().Success("Provider created successfully").Once() s.NoError(cmd.Handle(mockContext)) @@ -106,6 +109,7 @@ func (s *ProviderMakeCommandTestSuite) TestHandle() { // Test nested provider creation mockContext.EXPECT().Arguments().Return([]string{"auth/AuthServiceProvider"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Provider created successfully").Once() s.NoError(cmd.Handle(mockContext)) @@ -127,6 +131,7 @@ func (s *ProviderMakeCommandTestSuite) TestHandleMultipleNames() { mockContext := mocksconsole.NewContext(s.T()) mockContext.EXPECT().Arguments().Return([]string{"AuthServiceProvider", "EventServiceProvider"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Provider created successfully").Times(2) s.NoError(cmd.Handle(mockContext)) diff --git a/foundation/console/test_make_command_test.go b/foundation/console/test_make_command_test.go index 2e70dbd3c..93573ee0d 100644 --- a/foundation/console/test_make_command_test.go +++ b/foundation/console/test_make_command_test.go @@ -78,17 +78,20 @@ func (s *TestMakeCommandTestSuite) TestTestHandle() { s.Nil(cmd.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"UserTest"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Test created successfully").Once() s.NoError(cmd.Handle(mockContext)) s.True(file.Exists("tests/user_test.go")) mockContext.EXPECT().Arguments().Return([]string{"UserTest"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the test already exists. Use the --force or -f flag to overwrite").Once() s.Nil(cmd.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"user/UserTest"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Test created successfully").Once() s.NoError(cmd.Handle(mockContext)) @@ -110,6 +113,7 @@ func (s *TestMakeCommandTestSuite) TestHandleMultipleNames() { mockContext := mocksconsole.NewContext(s.T()) mockContext.EXPECT().Arguments().Return([]string{"UserTest", "PostTest"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Test created successfully").Times(2) s.NoError(cmd.Handle(mockContext)) diff --git a/http/console/controller_make_command.go b/http/console/controller_make_command.go index 8575f26ee..154acde20 100644 --- a/http/console/controller_make_command.go +++ b/http/console/controller_make_command.go @@ -38,6 +38,7 @@ func (r *ControllerMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the controller even if it already exists", }, + supportconsole.ModuleFlag(), }, } } diff --git a/http/console/controller_make_command_test.go b/http/console/controller_make_command_test.go index fbd19fe30..8af351019 100644 --- a/http/console/controller_make_command_test.go +++ b/http/console/controller_make_command_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/mock" mocksconsole "github.com/goravel/framework/mocks/console" + "github.com/goravel/framework/modular" "github.com/goravel/framework/support/file" ) @@ -21,18 +22,21 @@ func TestControllerMakeCommand(t *testing.T) { mockContext.EXPECT().Arguments().Return([]string{"UsersController"}).Once() mockContext.EXPECT().OptionBool("resource").Return(false).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Controller created successfully").Once() assert.Nil(t, controllerMakeCommand.Handle(mockContext)) assert.True(t, file.Exists("app/http/controllers/users_controller.go")) mockContext.EXPECT().Arguments().Return([]string{"UsersController"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the controller already exists. Use the --force or -f flag to overwrite").Once() assert.Nil(t, controllerMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"User/AuthController"}).Once() mockContext.EXPECT().OptionBool("resource").Return(false).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Controller created successfully").Once() assert.Nil(t, controllerMakeCommand.Handle(mockContext)) @@ -49,6 +53,7 @@ func TestControllerMakeCommand_MultipleNames(t *testing.T) { mockContext.EXPECT().Arguments().Return([]string{"UserController", "OrderController"}).Once() mockContext.EXPECT().OptionBool("resource").Return(false).Times(2) + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Controller created successfully").Times(2) @@ -71,6 +76,7 @@ func TestControllerMakeCommand_MultipleNamesPartialFailure(t *testing.T) { // alongside a new one. mockContext.EXPECT().Arguments().Return([]string{"UserController"}).Once() mockContext.EXPECT().OptionBool("resource").Return(false).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Controller created successfully").Once() assert.Nil(t, controllerMakeCommand.Handle(mockContext)) @@ -79,6 +85,7 @@ func TestControllerMakeCommand_MultipleNamesPartialFailure(t *testing.T) { // UserController: NewMake checks force, then fails because the file exists, // so it never reaches the resource flag. OrderController: checks force, then // resource, then succeeds. + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().OptionBool("resource").Return(false).Once() mockContext.EXPECT().Error("the controller already exists. Use the --force or -f flag to overwrite").Once() @@ -94,6 +101,7 @@ func TestResourceControllerMakeCommand(t *testing.T) { controllerMakeCommand := &ControllerMakeCommand{} mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"User/AuthController"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().OptionBool("resource").Return(true).Once() mockContext.EXPECT().Success("Controller created successfully").Once() @@ -108,3 +116,34 @@ func TestResourceControllerMakeCommand(t *testing.T) { assert.True(t, file.Contain("app/http/controllers/User/auth_controller.go", "func (r *AuthController) Destroy(ctx http.Context) http.Response {")) assert.Nil(t, file.Remove("app")) } + +// blogModule sets its own directory, so it is a module of the application. +type blogModule struct{} + +func (r *blogModule) Name() string { return "blog" } +func (r *blogModule) Path() string { return "modules/blog" } + +func TestControllerMakeCommand_Module(t *testing.T) { + registry := modular.NewRegistry("") + assert.NoError(t, registry.Register(&blogModule{})) + modular.SetDefault(registry) + defer modular.SetDefault(nil) + defer func() { assert.Nil(t, file.Remove("modules")) }() + + controllerMakeCommand := &ControllerMakeCommand{} + mockContext := mocksconsole.NewContext(t) + + mockContext.EXPECT().Arguments().Return([]string{"admin/PostController"}).Once() + mockContext.EXPECT().OptionBool("resource").Return(false).Once() + mockContext.EXPECT().Option("module").Return("Blog").Once() + mockContext.EXPECT().OptionBool("force").Return(false).Once() + mockContext.EXPECT().Success("Controller created successfully").Once() + assert.Nil(t, controllerMakeCommand.Handle(mockContext)) + assert.True(t, file.Contain("modules/blog/http/controllers/admin/post_controller.go", "package admin")) + assert.False(t, file.Exists("app")) + + mockContext.EXPECT().Arguments().Return([]string{"PostController"}).Once() + mockContext.EXPECT().Option("module").Return("news").Once() + mockContext.EXPECT().Error("[modular] module news is not registered").Once() + assert.Nil(t, controllerMakeCommand.Handle(mockContext)) +} diff --git a/http/console/middleware_make_command.go b/http/console/middleware_make_command.go index 80bce5c0f..8ebe9673e 100644 --- a/http/console/middleware_make_command.go +++ b/http/console/middleware_make_command.go @@ -33,6 +33,7 @@ func (r *MiddlewareMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the middleware even if it already exists", }, + supportconsole.ModuleFlag(), }, } } diff --git a/http/console/middleware_make_command_test.go b/http/console/middleware_make_command_test.go index a24b1e80a..8c2eac5d3 100644 --- a/http/console/middleware_make_command_test.go +++ b/http/console/middleware_make_command_test.go @@ -20,17 +20,20 @@ func TestMiddlewareMakeCommand(t *testing.T) { assert.NoError(t, middlewareMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"VerifyCsrfToken"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Middleware created successfully").Once() assert.NoError(t, middlewareMakeCommand.Handle(mockContext)) assert.True(t, file.Exists("app/http/middleware/verify_csrf_token.go")) mockContext.EXPECT().Arguments().Return([]string{"VerifyCsrfToken"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the middleware already exists. Use the --force or -f flag to overwrite").Once() assert.NoError(t, middlewareMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"User/Auth"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Middleware created successfully").Once() assert.NoError(t, middlewareMakeCommand.Handle(mockContext)) @@ -45,6 +48,7 @@ func TestMiddlewareMakeCommand_MultipleNames(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"Authenticate", "Throttle"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Middleware created successfully").Times(2) diff --git a/http/console/request_make_command.go b/http/console/request_make_command.go index 63b5bf061..ed8199914 100644 --- a/http/console/request_make_command.go +++ b/http/console/request_make_command.go @@ -33,6 +33,7 @@ func (r *RequestMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the request even if it already exists", }, + supportconsole.ModuleFlag(), }, } } diff --git a/http/console/request_make_command_test.go b/http/console/request_make_command_test.go index 237017cf4..a60b9a112 100644 --- a/http/console/request_make_command_test.go +++ b/http/console/request_make_command_test.go @@ -20,17 +20,20 @@ func TestRequestMakeCommand(t *testing.T) { assert.NoError(t, requestMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"CreateUser"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Request created successfully").Once() assert.NoError(t, requestMakeCommand.Handle(mockContext)) assert.True(t, file.Exists("app/http/requests/create_user.go")) mockContext.EXPECT().Arguments().Return([]string{"CreateUser"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the request already exists. Use the --force or -f flag to overwrite").Once() assert.NoError(t, requestMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"User/Auth"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Request created successfully").Once() assert.NoError(t, requestMakeCommand.Handle(mockContext)) @@ -45,6 +48,7 @@ func TestRequestMakeCommand_MultipleNames(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"StoreUser", "UpdateUser"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Request created successfully").Times(2) diff --git a/mail/console/mail_make_command.go b/mail/console/mail_make_command.go index c6b8600c2..519d3b341 100644 --- a/mail/console/mail_make_command.go +++ b/mail/console/mail_make_command.go @@ -37,6 +37,7 @@ func (r *MailMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the mail even if it already exists", }, + supportconsole.ModuleFlag(), }, } } diff --git a/mail/console/mail_make_command_test.go b/mail/console/mail_make_command_test.go index bd9d4618c..be521ceff 100644 --- a/mail/console/mail_make_command_test.go +++ b/mail/console/mail_make_command_test.go @@ -21,17 +21,20 @@ func TestMailMakeCommand(t *testing.T) { assert.False(t, file.Exists("app/mails/new_user.go")) mockContext.EXPECT().Arguments().Return([]string{"NewUser"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Mail created successfully").Once() assert.NoError(t, modelMakeCommand.Handle(mockContext)) assert.True(t, file.Exists("app/mails/new_user.go")) mockContext.EXPECT().Arguments().Return([]string{"NewUser"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the mail already exists. Use the --force or -f flag to overwrite").Once() assert.NoError(t, modelMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"User/VerifyEmail"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Mail created successfully").Once() assert.NoError(t, modelMakeCommand.Handle(mockContext)) @@ -47,6 +50,7 @@ func TestMailMakeCommand_MultipleNames(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"VerifyEmail", "ResetPassword"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Mail created successfully").Times(2) assert.NoError(t, mailMakeCommand.Handle(mockContext)) diff --git a/modular/prefix_fs_test.go b/modular/prefix_fs_test.go index 208800077..2e0872253 100644 --- a/modular/prefix_fs_test.go +++ b/modular/prefix_fs_test.go @@ -1,4 +1,4 @@ -package modular +package modular_test import ( "bytes" @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/goravel/framework/modular" "github.com/goravel/framework/support" "github.com/goravel/framework/view" ) @@ -21,11 +22,11 @@ func blogViews() fstest.MapFS { } func TestPrefixFS_TestFS(t *testing.T) { - assert.NoError(t, fstest.TestFS(NewPrefixFS("blog", blogViews()), "blog/index.tmpl", "blog/layouts/base.tmpl")) + assert.NoError(t, fstest.TestFS(modular.NewPrefixFS("blog", blogViews()), "blog/index.tmpl", "blog/layouts/base.tmpl")) } func TestPrefixFS_Paths(t *testing.T) { - fsys := NewPrefixFS("blog", blogViews()) + fsys := modular.NewPrefixFS("blog", blogViews()) entries, err := fs.ReadDir(fsys, ".") require.NoError(t, err) @@ -57,7 +58,7 @@ func TestPrefixFS_Views(t *testing.T) { t.Cleanup(func() { support.RelativePath = relativePath }) support.RelativePath = t.TempDir() - fsys := NewPrefixFS("blog", blogViews()) + fsys := modular.NewPrefixFS("blog", blogViews()) registry := view.NewView() assert.NotPanics(t, func() { registry.LoadViewsFromFS(fsys, ".") }) diff --git a/modular/registry.go b/modular/registry.go index e72eef252..c93ec69d2 100644 --- a/modular/registry.go +++ b/modular/registry.go @@ -3,7 +3,9 @@ package modular import ( + "runtime/debug" "slices" + "sync" "github.com/goravel/framework/contracts/foundation" "github.com/goravel/framework/contracts/modular" @@ -13,6 +15,36 @@ import ( var _ modular.Registry = (*Registry)(nil) +var ( + defaultMu sync.RWMutex + defaultRegistry modular.Registry +) + +// SetDefault records the application's registry for code that cannot reach the +// container (support/console). Build() calls it. +func SetDefault(registry modular.Registry) { + defaultMu.Lock() + defer defaultMu.Unlock() + + defaultRegistry = registry +} + +// Default returns the application's registry, nil before Build(). +func Default() modular.Registry { + defaultMu.RLock() + defer defaultMu.RUnlock() + + return defaultRegistry +} + +// Local reports whether module's sources are in the application, not in a dependency. +func Local(module modular.Module) bool { + info, _ := debug.ReadBuildInfo() + _, thirdParty := DependencyOf(module, info) + + return !thirdParty +} + // Registry keeps modules in registration order. type Registry struct { modules []modular.Module diff --git a/notification/console/make_command.go b/notification/console/make_command.go index 0f0c30717..21bb4d87e 100644 --- a/notification/console/make_command.go +++ b/notification/console/make_command.go @@ -37,6 +37,7 @@ func (r *NotificationMakeCommand) Extend() command.Extend { Name: "database", Usage: "Generate a notification template for the database channel instead of mail", }, + supportconsole.ModuleFlag(), }, } } diff --git a/notification/console/make_command_test.go b/notification/console/make_command_test.go index fdb5d9ebd..e453a5e46 100644 --- a/notification/console/make_command_test.go +++ b/notification/console/make_command_test.go @@ -22,6 +22,7 @@ func TestNotificationMakeCommand(t *testing.T) { assert.False(t, file.Exists("app/notifications/invoice_paid.go")) mockContext.EXPECT().Arguments().Return([]string{"InvoicePaid"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().OptionBool("database").Return(false).Once() mockContext.EXPECT().Success("Notification created successfully").Once() @@ -30,11 +31,13 @@ func TestNotificationMakeCommand(t *testing.T) { assert.True(t, file.Contain("app/notifications/invoice_paid.go", "func (r *InvoicePaid) ToMail")) mockContext.EXPECT().Arguments().Return([]string{"InvoicePaid"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Error("the notification already exists. Use the --force or -f flag to overwrite").Once() assert.NoError(t, notificationMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"Billing/InvoicePaid"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().OptionBool("database").Return(false).Once() mockContext.EXPECT().Success("Notification created successfully").Once() @@ -50,6 +53,7 @@ func TestNotificationMakeCommand_DatabaseFlag_GeneratesDatabaseTemplate(t *testi notificationMakeCommand := &NotificationMakeCommand{} mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"InvoicePaid"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().OptionBool("database").Return(true).Once() mockContext.EXPECT().Success("Notification created successfully").Once() @@ -69,7 +73,7 @@ func TestNotificationMakeCommand_Metadata(t *testing.T) { extend := cmd.Extend() assert.Equal(t, "make", extend.Category) - assert.Len(t, extend.Flags, 2) + assert.Len(t, extend.Flags, 3) assert.Equal(t, "force", extend.Flags[0].(*command.BoolFlag).Name) assert.Equal(t, "database", extend.Flags[1].(*command.BoolFlag).Name) } @@ -79,6 +83,7 @@ func TestNotificationMakeCommand_MultipleNames(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"InvoicePaid", "InvoiceRefunded"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().OptionBool("database").Return(false).Times(2) mockContext.EXPECT().Success("Notification created successfully").Times(2) diff --git a/queue/console/job_make_command_test.go b/queue/console/job_make_command_test.go index 8576cc2b5..d5baba9b9 100644 --- a/queue/console/job_make_command_test.go +++ b/queue/console/job_make_command_test.go @@ -90,6 +90,7 @@ func TestJobMakeCommand(t *testing.T) { assert.NoError(t, jobMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"GoravelJob"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Job created successfully").Once() mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { @@ -99,11 +100,13 @@ func TestJobMakeCommand(t *testing.T) { assert.True(t, file.Exists("app/jobs/goravel_job.go")) mockContext.On("Arguments").Return([]string{"GoravelJob"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.On("OptionBool", "force").Return(false).Once() mockContext.EXPECT().Error("the job already exists. Use the --force or -f flag to overwrite").Once() assert.NoError(t, jobMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"Goravel/Job"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Job created successfully").Once() mockContext.EXPECT().Success("Job registered successfully").Once() @@ -174,6 +177,7 @@ func TestJobMakeCommand_WithBootstrapSetup(t *testing.T) { jobMakeCommand := &JobMakeCommand{} mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{tt.jobName}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Job created successfully").Once() @@ -252,6 +256,7 @@ func TestJobMakeCommand_WithNonBootstrapSetup(t *testing.T) { jobMakeCommand := &JobMakeCommand{} mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{tt.jobName}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Job created successfully").Once() @@ -311,6 +316,7 @@ func TestJobMakeCommand_RegistrationError(t *testing.T) { jobMakeCommand := &JobMakeCommand{} mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"FailJob"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Job created successfully").Once() mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { @@ -336,6 +342,7 @@ func TestJobMakeCommand_RegistrationError(t *testing.T) { jobMakeCommand := &JobMakeCommand{} mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"ErrorJob"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Job created successfully").Once() mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { @@ -357,6 +364,7 @@ func TestJobMakeCommand_MultipleNames(t *testing.T) { assert.NoError(t, file.PutContent("app/providers/queue_service_provider.go", queueServiceProvider)) mockContext.EXPECT().Arguments().Return([]string{"SendEmail", "ProcessImage"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Job created successfully").Times(2) mockContext.EXPECT().Success("Job registered successfully").Times(2) diff --git a/support/console/console.go b/support/console/console.go index 053a83048..fac2150ec 100644 --- a/support/console/console.go +++ b/support/console/console.go @@ -2,6 +2,7 @@ package console import ( "fmt" + "path" "path/filepath" "runtime/debug" "strings" @@ -10,8 +11,11 @@ import ( "github.com/pterm/pterm" "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" "github.com/goravel/framework/errors" + "github.com/goravel/framework/modular" "github.com/goravel/framework/packages/paths" + "github.com/goravel/framework/support" "github.com/goravel/framework/support/color" "github.com/goravel/framework/support/file" "github.com/goravel/framework/support/str" @@ -20,6 +24,59 @@ import ( type Make struct { name string root string + // moduleDir is the directory of the --module target, "" for the application. + moduleDir string +} + +// ModuleFlag is the --module option of the make:* commands. +func ModuleFlag() command.Flag { + return &command.StringFlag{ + Name: "module", + Usage: "The module to create the file in", + } +} + +// ModuleDir returns the directory of the module given with --module, relative to +// the project root, or "" without the option. Third-party modules are refused. +func ModuleDir(ctx console.Context) (string, error) { + name := ctx.Option("module") + if name == "" { + return "", nil + } + + registry := modular.Default() + if registry == nil { + return "", errors.ModularRegistryNotSet + } + + module, ok := registry.Find(name) + if !ok { + module, ok = registry.Find(str.Of(name).Kebab().String()) + } + if !ok { + return "", errors.ModularModuleNotFound.Args(name) + } + if !modular.Local(module) { + return "", errors.ModularModuleNotLocal.Args(module.Name()) + } + + dir, _ := registry.Path(module.Name()) + + return dir, nil +} + +// ModuleRoot moves root into the module directory, dropping the application +// directory: "app/http/controllers" becomes "/http/controllers". +func ModuleRoot(dir, root string) string { + root = strings.Trim(filepath.ToSlash(root), "/") + app := strings.Trim(filepath.ToSlash(support.Config.Paths.App), "/") + if root == app { + root = "" + } else if app != "" { + root = strings.TrimPrefix(root, app+"/") + } + + return path.Join(dir, root) } // MakeNames returns every name passed to a make:* command. When no positional @@ -61,9 +118,18 @@ func NewMake(ctx console.Context, ttype, name, root string) (*Make, error) { } } + moduleDir, err := ModuleDir(ctx) + if err != nil { + return nil, err + } + if moduleDir != "" { + root = ModuleRoot(moduleDir, root) + } + m := &Make{ - name: name, - root: root, + name: name, + root: root, + moduleDir: moduleDir, } if !ctx.OptionBool("force") && file.Exists(m.GetFilePath()) { @@ -77,6 +143,11 @@ func (m *Make) GetName() string { return m.name } +// GetModuleDir returns the directory of the --module target, "" for the application. +func (m *Make) GetModuleDir() string { + return m.moduleDir +} + func (m *Make) GetFilePath() string { root := strings.Split(m.root, "/") filePaths := append(root, m.GetFolderPath(), str.Of(m.GetStructName()).Snake().String()+".go") diff --git a/support/console/console_test.go b/support/console/console_test.go index ce453cf06..3443680fd 100644 --- a/support/console/console_test.go +++ b/support/console/console_test.go @@ -1,6 +1,7 @@ package console import ( + "flag" "os" "path/filepath" "strings" @@ -12,9 +13,12 @@ import ( "github.com/stretchr/testify/suite" "github.com/goravel/framework/contracts/console" + contractsmodular "github.com/goravel/framework/contracts/modular" "github.com/goravel/framework/errors" mocksconsole "github.com/goravel/framework/mocks/console" + "github.com/goravel/framework/modular" "github.com/goravel/framework/support/color" + "github.com/goravel/framework/support/env" "github.com/goravel/framework/support/file" ) @@ -140,6 +144,7 @@ func TestNewMake(t *testing.T) { setup: func() { name = "Uppercase" assert.Nil(t, file.PutContent(filepath.Join(root, "uppercase.go"), "")) + mockCtx.EXPECT().Option("module").Return("").Once() mockCtx.EXPECT().OptionBool("force").Return(false).Once() }, expectMake: nil, @@ -150,6 +155,7 @@ func TestNewMake(t *testing.T) { setup: func() { name = "Uppercase" assert.Nil(t, file.PutContent(filepath.Join(root, "uppercase.go"), "")) + mockCtx.EXPECT().Option("module").Return("").Once() mockCtx.EXPECT().OptionBool("force").Return(true).Once() }, expectMake: &Make{name: "Lowercase", root: root}, @@ -159,6 +165,7 @@ func TestNewMake(t *testing.T) { name: "Happy path - name is not empty", setup: func() { name = "Lowercase" + mockCtx.EXPECT().Option("module").Return("").Once() mockCtx.On("OptionBool", "force").Return(false).Once() }, expectMake: &Make{name: "Lowercase", root: root}, @@ -181,6 +188,75 @@ func TestNewMake(t *testing.T) { } } +// testModule sets its own directory, so it is a module of the application. +type testModule struct { + name string + path string +} + +func (r *testModule) Name() string { return r.name } +func (r *testModule) Path() string { return r.path } + +func TestNewMakeInModule(t *testing.T) { + registry := modular.NewRegistry("") + // *flag.FlagSet has Name() and lives outside the application: a third-party module. + assert.NoError(t, registry.Register( + &testModule{name: "blog-posts", path: "modules/blog-posts"}, + flag.NewFlagSet("shop", flag.ContinueOnError), + )) + + tests := []struct { + name string + module string + registry contractsmodular.Registry + root string + expectRoot string + expectError error + }{ + {name: "app directory is dropped", module: "blog-posts", registry: registry, root: "app/http/controllers", expectRoot: "modules/blog-posts/http/controllers"}, + {name: "name is converted to kebab case", module: "BlogPosts", registry: registry, root: "app/models", expectRoot: "modules/blog-posts/models"}, + {name: "other directories keep their shape", module: "blog-posts", registry: registry, root: "database/migrations", expectRoot: "modules/blog-posts/database/migrations"}, + {name: "unknown module", module: "news", registry: registry, root: "app/models", expectError: errors.ModularModuleNotFound.Args("news")}, + {name: "third-party module", module: "shop", registry: registry, root: "app/models", expectError: errors.ModularModuleNotLocal.Args("shop")}, + {name: "no registry", module: "blog-posts", root: "app/models", expectError: errors.ModularRegistryNotSet}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + modular.SetDefault(test.registry) + defer modular.SetDefault(nil) + + mockCtx := mocksconsole.NewContext(t) + mockCtx.EXPECT().Option("module").Return(test.module).Once() + if test.expectError == nil { + mockCtx.EXPECT().OptionBool("force").Return(false).Once() + } + + m, err := NewMake(mockCtx, "model", "admin/User", test.root) + if test.expectError != nil { + assert.Equal(t, test.expectError, err) + assert.Nil(t, m) + + return + } + + pwd, _ := os.Getwd() + assert.NoError(t, err) + assert.Equal(t, "modules/blog-posts", m.GetModuleDir()) + assert.Equal(t, filepath.Join(pwd, filepath.FromSlash(test.expectRoot), "admin", "user.go"), m.GetFilePath()) + assert.Equal(t, env.MainPath()+"/"+test.expectRoot+"/admin", m.GetPackageImportPath()) + assert.Equal(t, "admin", m.GetPackageName()) + }) + } +} + +func TestModuleRoot(t *testing.T) { + assert.Equal(t, "modules/blog/http/controllers", ModuleRoot("modules/blog", "app/http/controllers")) + assert.Equal(t, "modules/blog", ModuleRoot("modules/blog", "app")) + assert.Equal(t, "modules/blog/application/models", ModuleRoot("modules/blog", "application/models")) + assert.Equal(t, "modules/blog/tests", ModuleRoot("modules/blog", filepath.Join("tests"))) +} + func TestHandleEach(t *testing.T) { tests := []struct { name string diff --git a/validation/console/filter_make_command.go b/validation/console/filter_make_command.go index b1f4df2f4..95f7fceb0 100644 --- a/validation/console/filter_make_command.go +++ b/validation/console/filter_make_command.go @@ -40,6 +40,7 @@ func (r *FilterMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the filter even if it already exists", }, + supportconsole.ModuleFlag(), }, } } diff --git a/validation/console/filter_make_command_test.go b/validation/console/filter_make_command_test.go index 5f2fefc4a..1c0a6490f 100644 --- a/validation/console/filter_make_command_test.go +++ b/validation/console/filter_make_command_test.go @@ -99,6 +99,7 @@ func TestFilterMakeCommand(t *testing.T) { assert.NoError(t, filterMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"Uppercase"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Filter created successfully").Once() mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { @@ -108,11 +109,13 @@ func TestFilterMakeCommand(t *testing.T) { assert.True(t, file.Exists("app/filters/uppercase.go")) mockContext.On("Arguments").Return([]string{"Uppercase"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.On("OptionBool", "force").Return(false).Once() mockContext.EXPECT().Error("the filter already exists. Use the --force or -f flag to overwrite").Once() assert.NoError(t, filterMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"User/Phone"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Filter created successfully").Once() mockContext.EXPECT().Success("Filter registered successfully").Once() @@ -183,6 +186,7 @@ func TestFilterMakeCommand_WithBootstrapSetup(t *testing.T) { filterMakeCommand := &FilterMakeCommand{} mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{tt.filterName}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Filter created successfully").Once() @@ -261,6 +265,7 @@ func TestFilterMakeCommand_WithNonBootstrapSetup(t *testing.T) { filterMakeCommand := &FilterMakeCommand{} mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{tt.filterName}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Filter created successfully").Once() @@ -320,6 +325,7 @@ func TestFilterMakeCommand_RegistrationError(t *testing.T) { filterMakeCommand := &FilterMakeCommand{} mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"FailFilter"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Filter created successfully").Once() mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { @@ -345,6 +351,7 @@ func TestFilterMakeCommand_RegistrationError(t *testing.T) { filterMakeCommand := &FilterMakeCommand{} mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"ErrorFilter"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Filter created successfully").Once() mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { @@ -366,6 +373,7 @@ func TestFilterMakeCommand_MultipleNames(t *testing.T) { assert.NoError(t, file.PutContent("app/providers/validation_service_provider.go", filterValidationServiceProvider)) mockContext.EXPECT().Arguments().Return([]string{"Uppercase", "Lowercase"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Filter created successfully").Times(2) mockContext.EXPECT().Success("Filter registered successfully").Times(2) @@ -389,6 +397,7 @@ func TestFilterMakeCommand_MultipleNamesPartialFailure(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"Uppercase", "Lowercase"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Filter created successfully").Times(2) mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { diff --git a/validation/console/rule_make_command.go b/validation/console/rule_make_command.go index b26e07228..9cc348142 100644 --- a/validation/console/rule_make_command.go +++ b/validation/console/rule_make_command.go @@ -40,6 +40,7 @@ func (r *RuleMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the rule even if it already exists", }, + supportconsole.ModuleFlag(), }, } } diff --git a/validation/console/rule_make_command_test.go b/validation/console/rule_make_command_test.go index 067dc4d79..bc8eb21ca 100644 --- a/validation/console/rule_make_command_test.go +++ b/validation/console/rule_make_command_test.go @@ -92,6 +92,7 @@ func TestRuleMakeCommand(t *testing.T) { assert.NoError(t, ruleMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"Uppercase"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Rule created successfully").Once() mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { @@ -101,11 +102,13 @@ func TestRuleMakeCommand(t *testing.T) { assert.True(t, file.Exists("app/rules/uppercase.go")) mockContext.On("Arguments").Return([]string{"Uppercase"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.On("OptionBool", "force").Return(false).Once() mockContext.EXPECT().Error("the rule already exists. Use the --force or -f flag to overwrite").Once() assert.NoError(t, ruleMakeCommand.Handle(mockContext)) mockContext.EXPECT().Arguments().Return([]string{"User/Phone"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Rule created successfully").Once() mockContext.EXPECT().Success("Rule registered successfully").Once() @@ -176,6 +179,7 @@ func TestRuleMakeCommand_WithBootstrapSetup(t *testing.T) { ruleMakeCommand := &RuleMakeCommand{} mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{tt.ruleName}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Rule created successfully").Once() @@ -254,6 +258,7 @@ func TestRuleMakeCommand_WithNonBootstrapSetup(t *testing.T) { ruleMakeCommand := &RuleMakeCommand{} mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{tt.ruleName}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Rule created successfully").Once() @@ -313,6 +318,7 @@ func TestRuleMakeCommand_RegistrationError(t *testing.T) { ruleMakeCommand := &RuleMakeCommand{} mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"FailRule"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Rule created successfully").Once() mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { @@ -338,6 +344,7 @@ func TestRuleMakeCommand_RegistrationError(t *testing.T) { ruleMakeCommand := &RuleMakeCommand{} mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Arguments().Return([]string{"ErrorRule"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Success("Rule created successfully").Once() mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { @@ -359,6 +366,7 @@ func TestRuleMakeCommand_MultipleNames(t *testing.T) { assert.NoError(t, file.PutContent("app/providers/validation_service_provider.go", ruleValidationServiceProvider)) mockContext.EXPECT().Arguments().Return([]string{"Uppercase", "Lowercase"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().OptionBool("force").Return(false).Times(2) mockContext.EXPECT().Success("Rule created successfully").Times(2) mockContext.EXPECT().Success("Rule registered successfully").Times(2) From 3a6eba6627212ced6b1ecfef76cbabe0a1d84b1a Mon Sep 17 00:00:00 2001 From: Saad Date: Wed, 11 Nov 2026 20:43:04 +0600 Subject: [PATCH 18/29] feat(database): make:migration --module make:migration create_posts_table --module=blog writes the migration to modules/blog/database/migrations and adds it to the module's Migrations() method, creating the method when the module does not have one. Migrator gains CreateIn(dir, name, modelName) for that; Create calls it with the default migrations path. --- contracts/database/migration/migrator.go | 2 + .../console/migration/migrate_make_command.go | 14 ++++- .../migration/migrate_make_command_test.go | 49 ++++++++++++++++ database/migration/migrator.go | 16 ++++- database/migration/migrator_test.go | 13 +++++ mocks/database/migration/Migrator.go | 58 +++++++++++++++++++ 6 files changed, 148 insertions(+), 4 deletions(-) diff --git a/contracts/database/migration/migrator.go b/contracts/database/migration/migrator.go index 4a927964e..873434229 100644 --- a/contracts/database/migration/migrator.go +++ b/contracts/database/migration/migrator.go @@ -14,6 +14,8 @@ type Status struct { type Migrator interface { // Create a new migration file. Create(name string, modelName string) (string, error) + // CreateIn creates a new migration file in dir, relative to the project root. + CreateIn(dir string, name string, modelName string) (string, error) // Fresh drops all tables and re-runs all migrations from scratch. Fresh() error // Reset the migrations. diff --git a/database/console/migration/migrate_make_command.go b/database/console/migration/migrate_make_command.go index 361fe0a87..b7f595c12 100644 --- a/database/console/migration/migrate_make_command.go +++ b/database/console/migration/migrate_make_command.go @@ -45,6 +45,7 @@ func (r *MigrateMakeCommand) Extend() command.Extend { Aliases: []string{"m"}, Usage: "The model name to use for generating the migration schema", }, + supportconsole.ModuleFlag(), }, } } @@ -69,7 +70,14 @@ func (r *MigrateMakeCommand) makeOne(ctx console.Context, name, modelName string return err } - fileName, err := r.migrator.Create(makeMigration.GetName(), modelName) + moduleDir := makeMigration.GetModuleDir() + + var fileName string + if moduleDir != "" { + fileName, err = r.migrator.CreateIn(supportconsole.ModuleRoot(moduleDir, support.Config.Paths.Migrations), makeMigration.GetName(), modelName) + } else { + fileName, err = r.migrator.Create(makeMigration.GetName(), modelName) + } if err != nil { return errors.MigrationCreateFailed.Args(err) } @@ -77,7 +85,9 @@ func (r *MigrateMakeCommand) makeOne(ctx console.Context, name, modelName string ctx.Success(fmt.Sprintf("Created Migration: %s", makeMigration.GetName())) structName := str.Of(fileName).Prepend("m_").Studly().String() - if env.IsBootstrapSetup() { + if moduleDir != "" { + err = modify.AddToModule(moduleDir, "Migrations", makeMigration.GetPackageImportPath(), fmt.Sprintf("&%s.%s{}", makeMigration.GetPackageName(), structName)) + } else if env.IsBootstrapSetup() { err = modify.AddMigration(makeMigration.GetPackageImportPath(), fmt.Sprintf("&%s.%s{}", makeMigration.GetPackageName(), structName)) } else { err = r.registerInKernel(makeMigration.GetPackageImportPath(), structName) diff --git a/database/console/migration/migrate_make_command_test.go b/database/console/migration/migrate_make_command_test.go index 666e188aa..e23694515 100644 --- a/database/console/migration/migrate_make_command_test.go +++ b/database/console/migration/migrate_make_command_test.go @@ -11,6 +11,8 @@ import ( mocksconsole "github.com/goravel/framework/mocks/console" mocksmigration "github.com/goravel/framework/mocks/database/migration" mocksfoundation "github.com/goravel/framework/mocks/foundation" + "github.com/goravel/framework/modular" + "github.com/goravel/framework/support/env" "github.com/goravel/framework/support/file" ) @@ -247,6 +249,53 @@ func Boot() { }) } +// blogModule sets its own directory, so it is a module of the application. +type blogModule struct{} + +func (r *blogModule) Name() string { return "blog" } +func (r *blogModule) Path() string { return "modules/blog" } + +func TestMigrateMakeCommand_Module(t *testing.T) { + registry := modular.NewRegistry("") + assert.NoError(t, registry.Register(&blogModule{})) + modular.SetDefault(registry) + defer modular.SetDefault(nil) + defer func() { + assert.NoError(t, file.Remove("modules")) + }() + + assert.NoError(t, file.PutContent("modules/blog/module.go", `package blog + +type Module struct{} + +func (m *Module) Name() string { + return "blog" +} +`)) + + mockContext := mocksconsole.NewContext(t) + mockMigrator := mocksmigration.NewMigrator(t) + + mockContext.EXPECT().Arguments().Return([]string{"create_posts_table"}).Once() + mockContext.EXPECT().Option("model").Return("").Once() + mockContext.EXPECT().Option("module").Return("blog").Once() + mockContext.EXPECT().OptionBool("force").Return(false).Once() + mockMigrator.EXPECT().CreateIn("modules/blog/database/migrations", "create_posts_table", "").Return("20240915060148_create_posts_table", nil).Once() + mockContext.EXPECT().Success("Created Migration: create_posts_table").Once() + mockContext.EXPECT().Success("Migration registered successfully").Once() + + assert.NoError(t, NewMigrateMakeCommand(mocksfoundation.NewApplication(t), mockMigrator).Handle(mockContext)) + + content, err := file.GetContent("modules/blog/module.go") + assert.NoError(t, err) + assert.Contains(t, content, env.MainPath()+"/modules/blog/database/migrations") + assert.Contains(t, content, `func (m *Module) Migrations() []schema.Migration { + return []schema.Migration{ + &migrations.M20240915060148CreatePostsTable{}, + } +}`) +} + func TestMigrateMakeCommand_MultipleNames(t *testing.T) { t.Cleanup(func() { _ = file.Remove("database") diff --git a/database/migration/migrator.go b/database/migration/migrator.go index 28a5d3115..4ee9ee82b 100644 --- a/database/migration/migrator.go +++ b/database/migration/migrator.go @@ -2,6 +2,7 @@ package migration import ( "fmt" + "path/filepath" "slices" "github.com/goravel/framework/contracts/console" @@ -14,6 +15,7 @@ import ( "github.com/goravel/framework/support/color" "github.com/goravel/framework/support/env" supportfile "github.com/goravel/framework/support/file" + "github.com/goravel/framework/support/path" "github.com/goravel/framework/support/str" ) @@ -34,6 +36,11 @@ func NewMigrator(artisan console.Artisan, schema contractsschema.Schema, table s } func (r *Migrator) Create(name string, modelName string) (string, error) { + return r.CreateIn("", name, modelName) +} + +// CreateIn writes the migration to dir instead of the migrations path, "" for the default. +func (r *Migrator) CreateIn(dir string, name string, modelName string) (string, error) { table, create := TableGuesser{}.Guess(name) var schemaFields []string @@ -59,10 +66,15 @@ func (r *Migrator) Create(name string, modelName string) (string, error) { facadesImport = "github.com/goravel/framework/facades" } + filePath, pkg := r.creator.GetPath(fileName), packages.Paths().Migrations().Package() + if dir != "" { + filePath, pkg = path.Base(filepath.FromSlash(dir), fileName+".go"), filepath.Base(filepath.FromSlash(dir)) + } + templateData := StubData{ FacadesPackage: packages.Paths().Facades().Package(), FacadesImport: facadesImport, - Package: packages.Paths().Migrations().Package(), + Package: pkg, SchemaFields: schemaFields, Signature: fileName, StructName: str.Of(fileName).Prepend("m_").Studly().String(), @@ -74,7 +86,7 @@ func (r *Migrator) Create(name string, modelName string) (string, error) { return "", err } - if err := supportfile.PutContent(r.creator.GetPath(fileName), content); err != nil { + if err := supportfile.PutContent(filePath, content); err != nil { return "", err } diff --git a/database/migration/migrator_test.go b/database/migration/migrator_test.go index 93e6acf0c..edff86e2e 100644 --- a/database/migration/migrator_test.go +++ b/database/migration/migrator_test.go @@ -68,6 +68,19 @@ func (s *MigratorSuite) TestCreate() { s.True(file.Exists(filepath.Join(path, "20240817214501_"+name+".go"))) }) + s.Run("in a directory", func() { + defer func() { + s.NoError(file.Remove("modules")) + }() + + name := "create_posts_table" + fileName, err := s.migrator.CreateIn("modules/blog/database/migrations", name, "") + s.NoError(err) + s.Equal("20240817214501_"+name, fileName) + s.True(file.Contain(filepath.Join(pwd, "modules", "blog", "database", "migrations", fileName+".go"), "package migrations")) + s.False(file.Exists(filepath.Join(path, fileName+".go"))) + }) + s.Run("model not found", func() { s.mockSchema.EXPECT().GetModel("NonExistentModel").Return(nil).Once() diff --git a/mocks/database/migration/Migrator.go b/mocks/database/migration/Migrator.go index 8f1e92902..adb320a41 100644 --- a/mocks/database/migration/Migrator.go +++ b/mocks/database/migration/Migrator.go @@ -77,6 +77,64 @@ func (_c *Migrator_Create_Call) RunAndReturn(run func(string, string) (string, e return _c } +// CreateIn provides a mock function with given fields: dir, name, modelName +func (_m *Migrator) CreateIn(dir string, name string, modelName string) (string, error) { + ret := _m.Called(dir, name, modelName) + + if len(ret) == 0 { + panic("no return value specified for CreateIn") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(string, string, string) (string, error)); ok { + return rf(dir, name, modelName) + } + if rf, ok := ret.Get(0).(func(string, string, string) string); ok { + r0 = rf(dir, name, modelName) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(string, string, string) error); ok { + r1 = rf(dir, name, modelName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Migrator_CreateIn_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateIn' +type Migrator_CreateIn_Call struct { + *mock.Call +} + +// CreateIn is a helper method to define mock.On call +// - dir string +// - name string +// - modelName string +func (_e *Migrator_Expecter) CreateIn(dir interface{}, name interface{}, modelName interface{}) *Migrator_CreateIn_Call { + return &Migrator_CreateIn_Call{Call: _e.mock.On("CreateIn", dir, name, modelName)} +} + +func (_c *Migrator_CreateIn_Call) Run(run func(dir string, name string, modelName string)) *Migrator_CreateIn_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(string), args[2].(string)) + }) + return _c +} + +func (_c *Migrator_CreateIn_Call) Return(_a0 string, _a1 error) *Migrator_CreateIn_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Migrator_CreateIn_Call) RunAndReturn(run func(string, string, string) (string, error)) *Migrator_CreateIn_Call { + _c.Call.Return(run) + return _c +} + // Fresh provides a mock function with no fields func (_m *Migrator) Fresh() error { ret := _m.Called() From b5562ebc4941eccf7cf2bf5a36f6a91f92781353 Mon Sep 17 00:00:00 2001 From: Saad Date: Wed, 11 Nov 2026 20:43:59 +0600 Subject: [PATCH 19/29] feat(modular): register generated commands, jobs, seeders and providers in the module With --module, make:command, make:job, make:seeder and make:provider write into the module and register the new type on the module struct (Commands, Jobs, Seeders, ServiceProviders) instead of bootstrap/. make:view --module writes to /resources/views and defines the template as "/", the name it is rendered by. make:test --module writes to /tests and embeds the application's TestCase. --- console/console/make_command.go | 5 +- console/console/make_command_test.go | 50 +++++++++++++++++ database/console/seeder_make_command.go | 5 +- database/console/seeder_make_command_test.go | 50 +++++++++++++++++ foundation/console/provider_make_command.go | 19 ++++--- .../console/provider_make_command_test.go | 53 ++++++++++++++++++- foundation/console/test_make_command.go | 4 +- foundation/console/test_make_command_test.go | 17 ++++++ queue/console/job_make_command.go | 5 +- queue/console/job_make_command_test.go | 50 +++++++++++++++++ support/console/console.go | 21 +++++--- tests/go.mod | 4 +- tests/go.sum | 4 ++ view/console/view_make_command.go | 16 +++++- view/console/view_make_command_test.go | 30 ++++++++++- 15 files changed, 310 insertions(+), 23 deletions(-) diff --git a/console/console/make_command.go b/console/console/make_command.go index 594c36b53..d3e3afb23 100644 --- a/console/console/make_command.go +++ b/console/console/make_command.go @@ -46,6 +46,7 @@ func (r *MakeCommand) Extend() command.Extend { Usage: "Create the command even if it already exists", DisableDefaultText: true, }, + supportconsole.ModuleFlag(), }, } } @@ -69,7 +70,9 @@ func (r *MakeCommand) makeOne(ctx console.Context, name string) error { ctx.Success("Console command created successfully") - if env.IsBootstrapSetup() { + if dir := make.GetModuleDir(); dir != "" { + err = modify.AddToModule(dir, "Commands", make.GetPackageImportPath(), fmt.Sprintf("&%s.%s{}", make.GetPackageName(), make.GetStructName())) + } else if env.IsBootstrapSetup() { err = modify.AddCommand(make.GetPackageImportPath(), fmt.Sprintf("&%s.%s{}", make.GetPackageName(), make.GetStructName())) } else { err = r.registerInKernel(make) diff --git a/console/console/make_command_test.go b/console/console/make_command_test.go index 2fb6a5c34..9d23305fc 100644 --- a/console/console/make_command_test.go +++ b/console/console/make_command_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/mock" mocksconsole "github.com/goravel/framework/mocks/console" + "github.com/goravel/framework/modular" "github.com/goravel/framework/support/file" ) @@ -223,3 +224,52 @@ func Commands() []console.Command { ` assert.Equal(t, expectedCommandsContent, commandsContent) } + +// blogModule sets its own directory, so it is a module of the application. +type blogModule struct{} + +func (r *blogModule) Name() string { return "blog" } +func (r *blogModule) Path() string { return "modules/blog" } + +// useBlogModule registers blogModule with a module.go on disk for the test. +func useBlogModule(t *testing.T) { + registry := modular.NewRegistry("") + assert.NoError(t, registry.Register(&blogModule{})) + modular.SetDefault(registry) + assert.NoError(t, file.PutContent("modules/blog/module.go", `package blog + +type Module struct{} + +func (m *Module) Name() string { + return "blog" +} +`)) + + t.Cleanup(func() { + modular.SetDefault(nil) + assert.NoError(t, file.Remove("modules")) + }) +} + +func TestMakeCommand_Module(t *testing.T) { + useBlogModule(t) + + mockContext := mocksconsole.NewContext(t) + mockContext.EXPECT().Arguments().Return([]string{"SyncPosts"}).Once() + mockContext.EXPECT().Option("module").Return("blog").Once() + mockContext.EXPECT().OptionBool("force").Return(false).Once() + mockContext.EXPECT().Success("Console command created successfully").Once() + mockContext.EXPECT().Success("Console command registered successfully").Once() + + assert.NoError(t, NewMakeCommand().Handle(mockContext)) + assert.True(t, file.Exists("modules/blog/console/commands/sync_posts.go")) + + content, err := file.GetContent("modules/blog/module.go") + assert.NoError(t, err) + assert.Contains(t, content, `/modules/blog/console/commands"`) + assert.Contains(t, content, `func (m *Module) Commands() []console.Command { + return []console.Command{ + &commands.SyncPosts{}, + } +}`) +} diff --git a/database/console/seeder_make_command.go b/database/console/seeder_make_command.go index 9a140d9bf..b44bf4d4b 100644 --- a/database/console/seeder_make_command.go +++ b/database/console/seeder_make_command.go @@ -46,6 +46,7 @@ func (r *SeederMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the seeder even if it already exists", }, + supportconsole.ModuleFlag(), }, } } @@ -69,7 +70,9 @@ func (r *SeederMakeCommand) makeOne(ctx console.Context, name string) error { ctx.Success("Seeder created successfully") - if env.IsBootstrapSetup() { + if dir := make.GetModuleDir(); dir != "" { + err = modify.AddToModule(dir, "Seeders", make.GetPackageImportPath(), fmt.Sprintf("&%s.%s{}", make.GetPackageName(), make.GetStructName())) + } else if env.IsBootstrapSetup() { err = modify.AddSeeder(make.GetPackageImportPath(), fmt.Sprintf("&%s.%s{}", make.GetPackageName(), make.GetStructName())) } else { err = r.registerInKernel(make) diff --git a/database/console/seeder_make_command_test.go b/database/console/seeder_make_command_test.go index d3ca0dfc7..2aad6d914 100644 --- a/database/console/seeder_make_command_test.go +++ b/database/console/seeder_make_command_test.go @@ -10,6 +10,7 @@ import ( mocksconsole "github.com/goravel/framework/mocks/console" mocksfoundation "github.com/goravel/framework/mocks/foundation" + "github.com/goravel/framework/modular" "github.com/goravel/framework/support/file" ) @@ -190,3 +191,52 @@ func TestSeederMakeCommand_MultipleNames(t *testing.T) { assert.True(t, file.Contain("database/kernel.go", "&seeders.UserSeeder{}")) assert.True(t, file.Contain("database/kernel.go", "&seeders.PostSeeder{}")) } + +// blogModule sets its own directory, so it is a module of the application. +type blogModule struct{} + +func (r *blogModule) Name() string { return "blog" } +func (r *blogModule) Path() string { return "modules/blog" } + +// useBlogModule registers blogModule with a module.go on disk for the test. +func useBlogModule(t *testing.T) { + registry := modular.NewRegistry("") + assert.NoError(t, registry.Register(&blogModule{})) + modular.SetDefault(registry) + assert.NoError(t, file.PutContent("modules/blog/module.go", `package blog + +type Module struct{} + +func (m *Module) Name() string { + return "blog" +} +`)) + + t.Cleanup(func() { + modular.SetDefault(nil) + assert.NoError(t, file.Remove("modules")) + }) +} + +func TestSeederMakeCommand_Module(t *testing.T) { + useBlogModule(t) + + mockContext := mocksconsole.NewContext(t) + mockContext.EXPECT().Arguments().Return([]string{"PostSeeder"}).Once() + mockContext.EXPECT().Option("module").Return("blog").Once() + mockContext.EXPECT().OptionBool("force").Return(false).Once() + mockContext.EXPECT().Success("Seeder created successfully").Once() + mockContext.EXPECT().Success("Seeder registered successfully").Once() + + assert.NoError(t, NewSeederMakeCommand(mocksfoundation.NewApplication(t)).Handle(mockContext)) + assert.True(t, file.Exists("modules/blog/database/seeders/post_seeder.go")) + + content, err := file.GetContent("modules/blog/module.go") + assert.NoError(t, err) + assert.Contains(t, content, `/modules/blog/database/seeders"`) + assert.Contains(t, content, `func (m *Module) Seeders() []seeder.Seeder { + return []seeder.Seeder{ + &seeders.PostSeeder{}, + } +}`) +} diff --git a/foundation/console/provider_make_command.go b/foundation/console/provider_make_command.go index b8586b69d..b221b29d1 100644 --- a/foundation/console/provider_make_command.go +++ b/foundation/console/provider_make_command.go @@ -41,6 +41,7 @@ func (r *ProviderMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the provider even if it already exists", }, + supportconsole.ModuleFlag(), }, } } @@ -66,14 +67,20 @@ func (r *ProviderMakeCommand) makeOne(ctx console.Context, name string) error { ctx.Success("Provider created successfully") - if env.IsBootstrapSetup() { - if err := modify.AddProvider(make.GetPackageImportPath(), fmt.Sprintf("&%s.%s{}", make.GetPackageName(), make.GetStructName())); err != nil { - return errors.ProviderRegisterFailed.Args(make.GetStructName(), err) - } - - ctx.Success("Provider registered successfully") + provider := fmt.Sprintf("&%s.%s{}", make.GetPackageName(), make.GetStructName()) + if dir := make.GetModuleDir(); dir != "" { + err = modify.AddToModule(dir, "ServiceProviders", make.GetPackageImportPath(), provider) + } else if env.IsBootstrapSetup() { + err = modify.AddProvider(make.GetPackageImportPath(), provider) + } else { + return nil + } + if err != nil { + return errors.ProviderRegisterFailed.Args(make.GetStructName(), err) } + ctx.Success("Provider registered successfully") + return nil } diff --git a/foundation/console/provider_make_command_test.go b/foundation/console/provider_make_command_test.go index 40a91bd82..9a2f9f99c 100644 --- a/foundation/console/provider_make_command_test.go +++ b/foundation/console/provider_make_command_test.go @@ -5,11 +5,13 @@ import ( "reflect" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" "github.com/goravel/framework/contracts/console/command" mocksconsole "github.com/goravel/framework/mocks/console" + "github.com/goravel/framework/modular" "github.com/goravel/framework/support/file" ) @@ -45,7 +47,7 @@ func (s *ProviderMakeCommandTestSuite) TestExtend() { }) s.Run("should have correct number of flags", func() { - s.Require().Len(extend.Flags, 1) + s.Require().Len(extend.Flags, 2) }) if len(extend.Flags) > 0 { @@ -139,3 +141,52 @@ func (s *ProviderMakeCommandTestSuite) TestHandleMultipleNames() { s.FileExists("app/providers/auth_service_provider.go") s.FileExists("app/providers/event_service_provider.go") } + +// blogModule sets its own directory, so it is a module of the application. +type blogModule struct{} + +func (r *blogModule) Name() string { return "blog" } +func (r *blogModule) Path() string { return "modules/blog" } + +// useBlogModule registers blogModule with a module.go on disk for the test. +func useBlogModule(t *testing.T) { + registry := modular.NewRegistry("") + assert.NoError(t, registry.Register(&blogModule{})) + modular.SetDefault(registry) + assert.NoError(t, file.PutContent("modules/blog/module.go", `package blog + +type Module struct{} + +func (m *Module) Name() string { + return "blog" +} +`)) + + t.Cleanup(func() { + modular.SetDefault(nil) + assert.NoError(t, file.Remove("modules")) + }) +} + +func TestProviderMakeCommand_Module(t *testing.T) { + useBlogModule(t) + + mockContext := mocksconsole.NewContext(t) + mockContext.EXPECT().Arguments().Return([]string{"BlogServiceProvider"}).Once() + mockContext.EXPECT().Option("module").Return("blog").Once() + mockContext.EXPECT().OptionBool("force").Return(false).Once() + mockContext.EXPECT().Success("Provider created successfully").Once() + mockContext.EXPECT().Success("Provider registered successfully").Once() + + assert.NoError(t, NewProviderMakeCommand().Handle(mockContext)) + assert.True(t, file.Exists("modules/blog/providers/blog_service_provider.go")) + + content, err := file.GetContent("modules/blog/module.go") + assert.NoError(t, err) + assert.Contains(t, content, `/modules/blog/providers"`) + assert.Contains(t, content, `func (m *Module) ServiceProviders() []foundation.ServiceProvider { + return []foundation.ServiceProvider{ + &providers.BlogServiceProvider{}, + } +}`) +} diff --git a/foundation/console/test_make_command.go b/foundation/console/test_make_command.go index 6b5be7810..b56a895d7 100644 --- a/foundation/console/test_make_command.go +++ b/foundation/console/test_make_command.go @@ -40,6 +40,7 @@ func (r *TestMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the test even if it already exists", }, + supportconsole.ModuleFlag(), }, } } @@ -60,7 +61,8 @@ func (r *TestMakeCommand) makeOne(ctx console.Context, filePath string) error { stub := r.getStub() var testCase, testsImport string - if str.Of(filePath).Contains("/", "\\") { + // A module has no TestCase of its own, it uses the application's. + if m.GetModuleDir() != "" || str.Of(filePath).Contains("/", "\\") { testCase = fmt.Sprintf("%s.TestCase", packages.Paths().Tests().Package()) testsImport = fmt.Sprintf(`"%s"`, packages.Paths().Tests().Import()) } else { diff --git a/foundation/console/test_make_command_test.go b/foundation/console/test_make_command_test.go index 93573ee0d..64d151fdf 100644 --- a/foundation/console/test_make_command_test.go +++ b/foundation/console/test_make_command_test.go @@ -5,6 +5,7 @@ import ( "reflect" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" @@ -121,3 +122,19 @@ func (s *TestMakeCommandTestSuite) TestHandleMultipleNames() { s.FileExists("tests/user_test.go") s.FileExists("tests/post_test.go") } + +func TestTestMakeCommand_Module(t *testing.T) { + useBlogModule(t) + + mockContext := mocksconsole.NewContext(t) + mockContext.EXPECT().Arguments().Return([]string{"PostTest"}).Once() + mockContext.EXPECT().Option("module").Return("blog").Once() + mockContext.EXPECT().OptionBool("force").Return(false).Once() + mockContext.EXPECT().Success("Test created successfully").Once() + + assert.NoError(t, (&TestMakeCommand{}).Handle(mockContext)) + assert.True(t, file.Contain("modules/blog/tests/post_test.go", "package tests")) + // The module has no TestCase, the application's is embedded. + assert.True(t, file.Contain("modules/blog/tests/post_test.go", "tests.TestCase")) + assert.True(t, file.Contain("modules/blog/tests/post_test.go", "/tests\"")) +} diff --git a/queue/console/job_make_command.go b/queue/console/job_make_command.go index 9e0dd92c5..ba0562c7a 100644 --- a/queue/console/job_make_command.go +++ b/queue/console/job_make_command.go @@ -40,6 +40,7 @@ func (r *JobMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the job even if it already exists", }, + supportconsole.ModuleFlag(), }, } } @@ -63,7 +64,9 @@ func (r *JobMakeCommand) makeOne(ctx console.Context, name string) error { ctx.Success("Job created successfully") - if env.IsBootstrapSetup() { + if dir := make.GetModuleDir(); dir != "" { + err = modify.AddToModule(dir, "Jobs", make.GetPackageImportPath(), fmt.Sprintf("&%s.%s{}", make.GetPackageName(), make.GetStructName())) + } else if env.IsBootstrapSetup() { err = modify.AddJob(make.GetPackageImportPath(), fmt.Sprintf("&%s.%s{}", make.GetPackageName(), make.GetStructName())) } else { err = r.registerInKernel(make) diff --git a/queue/console/job_make_command_test.go b/queue/console/job_make_command_test.go index d5baba9b9..61c083264 100644 --- a/queue/console/job_make_command_test.go +++ b/queue/console/job_make_command_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/mock" mocksconsole "github.com/goravel/framework/mocks/console" + "github.com/goravel/framework/modular" "github.com/goravel/framework/support" "github.com/goravel/framework/support/file" "github.com/goravel/framework/support/str" @@ -375,3 +376,52 @@ func TestJobMakeCommand_MultipleNames(t *testing.T) { assert.True(t, file.Contain("app/providers/queue_service_provider.go", "&jobs.SendEmail{}")) assert.True(t, file.Contain("app/providers/queue_service_provider.go", "&jobs.ProcessImage{}")) } + +// blogModule sets its own directory, so it is a module of the application. +type blogModule struct{} + +func (r *blogModule) Name() string { return "blog" } +func (r *blogModule) Path() string { return "modules/blog" } + +// useBlogModule registers blogModule with a module.go on disk for the test. +func useBlogModule(t *testing.T) { + registry := modular.NewRegistry("") + assert.NoError(t, registry.Register(&blogModule{})) + modular.SetDefault(registry) + assert.NoError(t, file.PutContent("modules/blog/module.go", `package blog + +type Module struct{} + +func (m *Module) Name() string { + return "blog" +} +`)) + + t.Cleanup(func() { + modular.SetDefault(nil) + assert.NoError(t, file.Remove("modules")) + }) +} + +func TestJobMakeCommand_Module(t *testing.T) { + useBlogModule(t) + + mockContext := mocksconsole.NewContext(t) + mockContext.EXPECT().Arguments().Return([]string{"NotifyAuthor"}).Once() + mockContext.EXPECT().Option("module").Return("blog").Once() + mockContext.EXPECT().OptionBool("force").Return(false).Once() + mockContext.EXPECT().Success("Job created successfully").Once() + mockContext.EXPECT().Success("Job registered successfully").Once() + + assert.NoError(t, (&JobMakeCommand{}).Handle(mockContext)) + assert.True(t, file.Exists("modules/blog/jobs/notify_author.go")) + + content, err := file.GetContent("modules/blog/module.go") + assert.NoError(t, err) + assert.Contains(t, content, `/modules/blog/jobs"`) + assert.Contains(t, content, `func (m *Module) Jobs() []queue.Job { + return []queue.Job{ + &jobs.NotifyAuthor{}, + } +}`) +} diff --git a/support/console/console.go b/support/console/console.go index fac2150ec..9fbfa9010 100644 --- a/support/console/console.go +++ b/support/console/console.go @@ -39,14 +39,21 @@ func ModuleFlag() command.Flag { // ModuleDir returns the directory of the module given with --module, relative to // the project root, or "" without the option. Third-party modules are refused. func ModuleDir(ctx console.Context) (string, error) { - name := ctx.Option("module") + _, dir, err := Module(ctx) + + return dir, err +} + +// Module is ModuleDir with the registered name of the module. +func Module(ctx console.Context) (name, dir string, err error) { + name = ctx.Option("module") if name == "" { - return "", nil + return "", "", nil } registry := modular.Default() if registry == nil { - return "", errors.ModularRegistryNotSet + return "", "", errors.ModularRegistryNotSet } module, ok := registry.Find(name) @@ -54,15 +61,15 @@ func ModuleDir(ctx console.Context) (string, error) { module, ok = registry.Find(str.Of(name).Kebab().String()) } if !ok { - return "", errors.ModularModuleNotFound.Args(name) + return "", "", errors.ModularModuleNotFound.Args(name) } if !modular.Local(module) { - return "", errors.ModularModuleNotLocal.Args(module.Name()) + return "", "", errors.ModularModuleNotLocal.Args(module.Name()) } - dir, _ := registry.Path(module.Name()) + dir, _ = registry.Path(module.Name()) - return dir, nil + return module.Name(), dir, nil } // ModuleRoot moves root into the module directory, dropping the application diff --git a/tests/go.mod b/tests/go.mod index 550ab21a5..923ea6747 100644 --- a/tests/go.mod +++ b/tests/go.mod @@ -103,7 +103,7 @@ require ( go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/crypto v0.58.0 // indirect - golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect + golang.org/x/exp v0.0.0-20261106192419-47ce03d25ef0 // indirect golang.org/x/net v0.61.0 // indirect golang.org/x/sync v0.24.0 // indirect golang.org/x/sys v0.49.0 // indirect @@ -111,7 +111,7 @@ require ( golang.org/x/text v0.43.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc // indirect - google.golang.org/grpc v1.86.0-dev.0.20261029074820-bc90736a6fad // indirect + google.golang.org/grpc v1.86.0-dev.0.20261104142343-cbf30c413099 // indirect google.golang.org/protobuf v1.36.12 // indirect gorm.io/driver/mysql v1.6.0 // indirect gorm.io/driver/postgres v1.6.0 // indirect diff --git a/tests/go.sum b/tests/go.sum index f9200d666..8767ad193 100644 --- a/tests/go.sum +++ b/tests/go.sum @@ -309,6 +309,8 @@ golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e h1:HgI6W1OolKK6tcV74is+rl9SMT0njsHlp0TbQiGXI/A= golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw= +golang.org/x/exp v0.0.0-20261106192419-47ce03d25ef0 h1:1GO4fJiyGakz0Liu2Ve1qPAwQYPkKlG1Q8iO2CYjtFA= +golang.org/x/exp v0.0.0-20261106192419-47ce03d25ef0/go.mod h1:J3U+E8RzytcGvwz+uJuAP4zULJ2TkAQBnmPZMexpdXs= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -416,6 +418,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= google.golang.org/grpc v1.86.0-dev.0.20261029074820-bc90736a6fad h1:UYg8dzXN2/IA54SnxVYwWjd5VQm3PFP3xn4aBz+RDJ8= google.golang.org/grpc v1.86.0-dev.0.20261029074820-bc90736a6fad/go.mod h1:Zr6uoNHvZlKOMmDEULT/cdZrN8qcSkXRXPkw4ybjoxI= +google.golang.org/grpc v1.86.0-dev.0.20261104142343-cbf30c413099 h1:GMGAZI0ozNRMHMV+etj3fihG3abcnkIkZl12Ut8vjH4= +google.golang.org/grpc v1.86.0-dev.0.20261104142343-cbf30c413099/go.mod h1:Zr6uoNHvZlKOMmDEULT/cdZrN8qcSkXRXPkw4ybjoxI= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/view/console/view_make_command.go b/view/console/view_make_command.go index ac92dbf1c..e7251b2ae 100644 --- a/view/console/view_make_command.go +++ b/view/console/view_make_command.go @@ -1,12 +1,14 @@ package console import ( + "path/filepath" "strings" "github.com/goravel/framework/contracts/config" "github.com/goravel/framework/contracts/console" "github.com/goravel/framework/contracts/console/command" "github.com/goravel/framework/errors" + "github.com/goravel/framework/support" supportconsole "github.com/goravel/framework/support/console" "github.com/goravel/framework/support/file" "github.com/goravel/framework/support/path" @@ -46,6 +48,7 @@ func (r *ViewMakeCommand) Extend() command.Extend { Aliases: []string{"f"}, Usage: "Create the view even if it already exists", }, + supportconsole.ModuleFlag(), }, } } @@ -70,7 +73,16 @@ func (r *ViewMakeCommand) makeOne(ctx console.Context, viewName string) error { viewName = viewName + viewExtension } - filePath := path.View(viewName) + // A module view "index.tmpl" is rendered as "/index.tmpl". + filePath, definition := path.View(viewName), viewName + moduleName, moduleDir, err := supportconsole.Module(ctx) + if err != nil { + return err + } + if moduleDir != "" { + filePath = path.Base(filepath.FromSlash(supportconsole.ModuleRoot(moduleDir, support.Config.Paths.Views)), viewName) + definition = moduleName + "/" + viewName + } // Check if file already exists if file.Exists(filePath) && !ctx.OptionBool("force") { @@ -79,7 +91,7 @@ func (r *ViewMakeCommand) makeOne(ctx console.Context, viewName string) error { // Create the view file stub := r.getStub() - content := r.populateStub(stub, viewName) + content := r.populateStub(stub, definition) if err := file.PutContent(filePath, content); err != nil { return err diff --git a/view/console/view_make_command_test.go b/view/console/view_make_command_test.go index 6bd6687b7..925ce1035 100644 --- a/view/console/view_make_command_test.go +++ b/view/console/view_make_command_test.go @@ -8,6 +8,7 @@ import ( "github.com/goravel/framework/contracts/console/command" mocksconsole "github.com/goravel/framework/mocks/console" + "github.com/goravel/framework/modular" "github.com/goravel/framework/support/file" ) @@ -34,7 +35,7 @@ func (s *ViewMakeCommandTestSuite) TestExtend() { extend := cmd.Extend() s.Equal("make", extend.Category) - s.Len(extend.Flags, 1) + s.Len(extend.Flags, 2) // Test BoolFlag boolFlag, ok := extend.Flags[0].(*command.BoolFlag) @@ -58,6 +59,7 @@ func (s *ViewMakeCommandTestSuite) TestCreateSuccess() { mockContext := mocksconsole.NewContext(s.T()) mockContext.EXPECT().Arguments().Return([]string{"welcome"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() expectedPath := filepath.Join("resources", "views", "welcome.tmpl") mockContext.EXPECT().Success("View created successfully").Once() @@ -123,9 +125,35 @@ func (s *ViewMakeCommandTestSuite) TestHandleMultipleNames() { mockContext := mocksconsole.NewContext(s.T()) mockContext.EXPECT().Arguments().Return([]string{"welcome", "dashboard"}).Once() + mockContext.EXPECT().Option("module").Return("").Times(2) mockContext.EXPECT().Success("View created successfully").Times(2) s.Nil(viewMakeCommand.Handle(mockContext)) s.FileExists(filepath.Join("resources", "views", "welcome.tmpl")) s.FileExists(filepath.Join("resources", "views", "dashboard.tmpl")) } + +// blogModule sets its own directory, so it is a module of the application. +type blogModule struct{} + +func (r *blogModule) Name() string { return "blog" } +func (r *blogModule) Path() string { return "modules/blog" } + +func (s *ViewMakeCommandTestSuite) TestModule() { + registry := modular.NewRegistry("") + s.NoError(registry.Register(&blogModule{})) + modular.SetDefault(registry) + s.T().Cleanup(func() { + modular.SetDefault(nil) + _ = file.Remove("modules") + }) + + mockContext := mocksconsole.NewContext(s.T()) + mockContext.EXPECT().Arguments().Return([]string{"posts/index"}).Once() + mockContext.EXPECT().Option("module").Return("Blog").Once() + mockContext.EXPECT().Success("View created successfully").Once() + + s.Nil((&ViewMakeCommand{}).Handle(mockContext)) + s.True(file.Contain(filepath.Join("modules", "blog", "resources", "views", "posts", "index.tmpl"), `{{ define "blog/posts/index.tmpl" }}`)) + s.NoDirExists("resources") +} From a2710da8464f6947eff2f96bb4b499642e25755a Mon Sep 17 00:00:00 2001 From: Saad Date: Wed, 11 Nov 2026 21:18:54 +0600 Subject: [PATCH 20/29] chore: tidy tests/go.sum --- tests/go.sum | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/go.sum b/tests/go.sum index 8767ad193..071e7574f 100644 --- a/tests/go.sum +++ b/tests/go.sum @@ -307,8 +307,6 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= -golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e h1:HgI6W1OolKK6tcV74is+rl9SMT0njsHlp0TbQiGXI/A= -golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw= golang.org/x/exp v0.0.0-20261106192419-47ce03d25ef0 h1:1GO4fJiyGakz0Liu2Ve1qPAwQYPkKlG1Q8iO2CYjtFA= golang.org/x/exp v0.0.0-20261106192419-47ce03d25ef0/go.mod h1:J3U+E8RzytcGvwz+uJuAP4zULJ2TkAQBnmPZMexpdXs= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -407,8 +405,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs= -golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs= +golang.org/x/tools v0.52.1-0.20261106210606-4f7264d11672 h1:VfrTmiWA9+TdDrL9VQr7aeGonnRLaEBwN3/D7jzL25Y= +golang.org/x/tools v0.52.1-0.20261106210606-4f7264d11672/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= @@ -416,8 +414,6 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc h1: google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:K/n0XLazCJHjD73YuMyNJsN38BPPy1kfFnKxUXaThFU= google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg= google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= -google.golang.org/grpc v1.86.0-dev.0.20261029074820-bc90736a6fad h1:UYg8dzXN2/IA54SnxVYwWjd5VQm3PFP3xn4aBz+RDJ8= -google.golang.org/grpc v1.86.0-dev.0.20261029074820-bc90736a6fad/go.mod h1:Zr6uoNHvZlKOMmDEULT/cdZrN8qcSkXRXPkw4ybjoxI= google.golang.org/grpc v1.86.0-dev.0.20261104142343-cbf30c413099 h1:GMGAZI0ozNRMHMV+etj3fihG3abcnkIkZl12Ut8vjH4= google.golang.org/grpc v1.86.0-dev.0.20261104142343-cbf30c413099/go.mod h1:Zr6uoNHvZlKOMmDEULT/cdZrN8qcSkXRXPkw4ybjoxI= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= From f4895f16355957341d54cff03759017f7dce3d2b Mon Sep 17 00:00:00 2001 From: Saad Date: Wed, 11 Nov 2026 21:53:22 +0600 Subject: [PATCH 21/29] fix: improve code coverage --- auth/console/module_flag_test.go | 28 ++++ broadcasting/console/module_flag_test.go | 28 ++++ console/console/module_flag_test.go | 29 ++++ .../console/migration/module_flag_test.go | 28 ++++ database/console/module_flag_test.go | 31 ++++ event/console/module_flag_test.go | 29 ++++ foundation/application_modules_test.go | 40 ++++++ .../console/provider_make_command_test.go | 57 ++++++++ foundation/provider_repository_test.go | 8 ++ http/console/module_flag_test.go | 30 ++++ mail/console/module_flag_test.go | 28 ++++ modular/console/init_command_test.go | 44 ++++++ modular/console/module_make_command_test.go | 79 ++++++++++ modular/console/publish_command_test.go | 19 +++ modular/lang_fs_test.go | 18 +++ modular/prefix_fs_test.go | 96 +++++++++++++ modular/publish_test.go | 92 ++++++++++++ modular/registry_test.go | 30 ++++ modular/static_test.go | 16 +++ packages/modify/utils_test.go | 136 ++++++++++++++++++ queue/console/module_flag_test.go | 28 ++++ support/console/console_test.go | 9 ++ validation/console/module_flag_test.go | 29 ++++ view/console/view_make_command_test.go | 15 ++ vite/vite_test.go | 13 ++ 25 files changed, 960 insertions(+) create mode 100644 auth/console/module_flag_test.go create mode 100644 broadcasting/console/module_flag_test.go create mode 100644 console/console/module_flag_test.go create mode 100644 database/console/migration/module_flag_test.go create mode 100644 database/console/module_flag_test.go create mode 100644 event/console/module_flag_test.go create mode 100644 http/console/module_flag_test.go create mode 100644 mail/console/module_flag_test.go create mode 100644 queue/console/module_flag_test.go create mode 100644 validation/console/module_flag_test.go diff --git a/auth/console/module_flag_test.go b/auth/console/module_flag_test.go new file mode 100644 index 000000000..1654a35e7 --- /dev/null +++ b/auth/console/module_flag_test.go @@ -0,0 +1,28 @@ +package console + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" +) + +// Every make command of the package accepts --module. +func TestMakeCommands_ModuleFlag(t *testing.T) { + commands := []console.Command{ + NewPolicyMakeCommand(), + } + + for _, cmd := range commands { + var names []string + for _, flag := range cmd.Extend().Flags { + if f, ok := flag.(*command.StringFlag); ok { + names = append(names, f.Name) + } + } + + assert.Contains(t, names, "module", cmd.Signature()) + } +} diff --git a/broadcasting/console/module_flag_test.go b/broadcasting/console/module_flag_test.go new file mode 100644 index 000000000..be71ac93b --- /dev/null +++ b/broadcasting/console/module_flag_test.go @@ -0,0 +1,28 @@ +package console + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" +) + +// Every make command of the package accepts --module. +func TestMakeCommands_ModuleFlag(t *testing.T) { + commands := []console.Command{ + NewChannelMakeCommand(), + } + + for _, cmd := range commands { + var names []string + for _, flag := range cmd.Extend().Flags { + if f, ok := flag.(*command.StringFlag); ok { + names = append(names, f.Name) + } + } + + assert.Contains(t, names, "module", cmd.Signature()) + } +} diff --git a/console/console/module_flag_test.go b/console/console/module_flag_test.go new file mode 100644 index 000000000..324052918 --- /dev/null +++ b/console/console/module_flag_test.go @@ -0,0 +1,29 @@ +package console + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" +) + +// Every make command of the package accepts --module. +func TestMakeCommands_ModuleFlag(t *testing.T) { + commands := []console.Command{ + NewMakeCommand(), + NewMakeCommandHook(), + } + + for _, cmd := range commands { + var names []string + for _, flag := range cmd.Extend().Flags { + if f, ok := flag.(*command.StringFlag); ok { + names = append(names, f.Name) + } + } + + assert.Contains(t, names, "module", cmd.Signature()) + } +} diff --git a/database/console/migration/module_flag_test.go b/database/console/migration/module_flag_test.go new file mode 100644 index 000000000..8fc951a2b --- /dev/null +++ b/database/console/migration/module_flag_test.go @@ -0,0 +1,28 @@ +package migration + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" +) + +// Every make command of the package accepts --module. +func TestMakeCommands_ModuleFlag(t *testing.T) { + commands := []console.Command{ + NewMigrateMakeCommand(nil, nil), + } + + for _, cmd := range commands { + var names []string + for _, flag := range cmd.Extend().Flags { + if f, ok := flag.(*command.StringFlag); ok { + names = append(names, f.Name) + } + } + + assert.Contains(t, names, "module", cmd.Signature()) + } +} diff --git a/database/console/module_flag_test.go b/database/console/module_flag_test.go new file mode 100644 index 000000000..672a4c69c --- /dev/null +++ b/database/console/module_flag_test.go @@ -0,0 +1,31 @@ +package console + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" +) + +// Every make command of the package accepts --module. +func TestMakeCommands_ModuleFlag(t *testing.T) { + commands := []console.Command{ + NewFactoryMakeCommand(), + NewModelMakeCommand(nil, nil), + NewObserverMakeCommand(), + NewSeederMakeCommand(nil), + } + + for _, cmd := range commands { + var names []string + for _, flag := range cmd.Extend().Flags { + if f, ok := flag.(*command.StringFlag); ok { + names = append(names, f.Name) + } + } + + assert.Contains(t, names, "module", cmd.Signature()) + } +} diff --git a/event/console/module_flag_test.go b/event/console/module_flag_test.go new file mode 100644 index 000000000..bddb5ad98 --- /dev/null +++ b/event/console/module_flag_test.go @@ -0,0 +1,29 @@ +package console + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" +) + +// Every make command of the package accepts --module. +func TestMakeCommands_ModuleFlag(t *testing.T) { + commands := []console.Command{ + &EventMakeCommand{}, + &ListenerMakeCommand{}, + } + + for _, cmd := range commands { + var names []string + for _, flag := range cmd.Extend().Flags { + if f, ok := flag.(*command.StringFlag); ok { + names = append(names, f.Name) + } + } + + assert.Contains(t, names, "module", cmd.Signature()) + } +} diff --git a/foundation/application_modules_test.go b/foundation/application_modules_test.go index c8f5b7002..7f2fe6950 100644 --- a/foundation/application_modules_test.go +++ b/foundation/application_modules_test.go @@ -1,6 +1,7 @@ package foundation import ( + "io" "io/fs" "net/http" "slices" @@ -20,6 +21,7 @@ import ( "github.com/goravel/framework/contracts/queue" "github.com/goravel/framework/contracts/route" "github.com/goravel/framework/contracts/schedule" + "github.com/goravel/framework/errors" mocksconfig "github.com/goravel/framework/mocks/config" mocksconsole "github.com/goravel/framework/mocks/console" mocksschema "github.com/goravel/framework/mocks/database/schema" @@ -29,6 +31,7 @@ import ( mocksroute "github.com/goravel/framework/mocks/route" mocksschedule "github.com/goravel/framework/mocks/schedule" mocksview "github.com/goravel/framework/mocks/view" + "github.com/goravel/framework/support/color" ) // testModule has only a name; tests embed it to add capabilities. @@ -469,3 +472,40 @@ func (s *ApplicationTestSuite) TestConfigureRoutes_ModuleStatic() { ) s.app.configureRoutes() } + +// Capabilities whose facade is not installed are reported, not fatal. +func (s *ApplicationTestSuite) TestModules_FacadesNotInstalled() { + s.SetupTest() + routed := []string{} + s.withModules(NewApplicationBuilder(s.app), + &richModule{ + testModule: testModule{"blog"}, + config: map[string]any{"enabled": true}, + translation: fstest.MapFS{"en/messages.json": {Data: []byte(`{}`)}}, + views: fstest.MapFS{"index.tmpl": {Data: []byte("x")}}, + migrations: []schema.Migration{mocksschema.NewMigration(s.T())}, + seeders: []seeder.Seeder{mocksseeder.NewSeeder(s.T())}, + jobs: []queue.Job{mocksqueue.NewJob(s.T())}, + schedule: []schedule.Event{mocksschedule.NewEvent(s.T())}, + events: map[event.Event][]event.Listener{mocksevent.NewEvent(s.T()): {mocksevent.NewListener(s.T())}}, + routed: &routed, + }, + ) + + output := color.CaptureOutput(func(io.Writer) { + s.app.configureModuleConfig() + s.app.configureEventListeners() + s.app.configureJobs() + s.app.configureMigrations() + s.app.configureRoutes() + s.app.configureSchedule() + s.app.configureSeeders() + s.app.registerModuleResources() + }) + + s.Contains(output, errors.ConfigFacadeNotSet.SetModule(errors.ModuleModular).Error()) + for _, facade := range []string{"Event", "Queue", "Schema", "Route", "Schedule", "Seeder", "View"} { + s.Contains(output, facade+" facade not found") + } + s.Empty(routed) +} diff --git a/foundation/console/provider_make_command_test.go b/foundation/console/provider_make_command_test.go index 9a2f9f99c..d2d6c506a 100644 --- a/foundation/console/provider_make_command_test.go +++ b/foundation/console/provider_make_command_test.go @@ -2,7 +2,9 @@ package console import ( "errors" + "path/filepath" "reflect" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -12,6 +14,7 @@ import ( "github.com/goravel/framework/contracts/console/command" mocksconsole "github.com/goravel/framework/mocks/console" "github.com/goravel/framework/modular" + "github.com/goravel/framework/support" "github.com/goravel/framework/support/file" ) @@ -190,3 +193,57 @@ func TestProviderMakeCommand_Module(t *testing.T) { } }`) } + +func TestProviderMakeCommand_ModuleRegistrationError(t *testing.T) { + useBlogModule(t) + // ServiceProviders() does not return a slice literal, so it cannot be edited. + assert.NoError(t, file.PutContent("modules/blog/providers.go", `package blog + +import "github.com/goravel/framework/contracts/foundation" + +func (m *Module) ServiceProviders() []foundation.ServiceProvider { + return nil +} +`)) + + mockContext := mocksconsole.NewContext(t) + mockContext.EXPECT().Arguments().Return([]string{"BlogServiceProvider"}).Once() + mockContext.EXPECT().Option("module").Return("blog").Once() + mockContext.EXPECT().OptionBool("force").Return(false).Once() + mockContext.EXPECT().Success("Provider created successfully").Once() + mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { + return strings.Contains(msg, "BlogServiceProvider") + })).Once() + + assert.NoError(t, NewProviderMakeCommand().Handle(mockContext)) + assert.True(t, file.Exists("modules/blog/providers/blog_service_provider.go")) +} + +func TestProviderMakeCommand_BootstrapSetup(t *testing.T) { + bootstrapDir := support.Config.Paths.Bootstrap + assert.NoError(t, file.PutContent(filepath.Join(bootstrapDir, "app.go"), `package bootstrap + +import ( + contractsfoundation "github.com/goravel/framework/contracts/foundation" + "github.com/goravel/framework/foundation" +) + +func Boot() contractsfoundation.Application { + return foundation.Setup().Start() +} +`)) + t.Cleanup(func() { + assert.NoError(t, file.Remove(bootstrapDir)) + assert.NoError(t, file.Remove("app")) + }) + + mockContext := mocksconsole.NewContext(t) + mockContext.EXPECT().Arguments().Return([]string{"UserServiceProvider"}).Once() + mockContext.EXPECT().Option("module").Return("").Once() + mockContext.EXPECT().OptionBool("force").Return(false).Once() + mockContext.EXPECT().Success("Provider created successfully").Once() + mockContext.EXPECT().Success("Provider registered successfully").Once() + + assert.NoError(t, NewProviderMakeCommand().Handle(mockContext)) + assert.True(t, file.Contain(filepath.Join(bootstrapDir, "providers.go"), "&providers.UserServiceProvider{}")) +} diff --git a/foundation/provider_repository_test.go b/foundation/provider_repository_test.go index e46870f00..4a9109837 100644 --- a/foundation/provider_repository_test.go +++ b/foundation/provider_repository_test.go @@ -61,6 +61,14 @@ func (s *ProviderRepositoryTestSuite) TestAdd_SameTypeNameInDifferentPackages() s.Equal("*github.com/goravel/framework/mocks/foundation.ServiceProvider", s.repository.providerKey(mockProvider)) } +// A type without a name of its own is keyed by its %T form. +func (s *ProviderRepositoryTestSuite) TestProviderKey_UnnamedType() { + provider := struct{ foundation.ServiceProvider }{} + + s.Equal("struct { foundation.ServiceProvider }", s.repository.providerKey(provider)) + s.Equal("*struct { foundation.ServiceProvider }", s.repository.providerKey(&provider)) +} + func (s *ProviderRepositoryTestSuite) TestAdd_Duplicates() { providerA := &AServiceProvider{} diff --git a/http/console/module_flag_test.go b/http/console/module_flag_test.go new file mode 100644 index 000000000..71117ece5 --- /dev/null +++ b/http/console/module_flag_test.go @@ -0,0 +1,30 @@ +package console + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" +) + +// Every make command of the package accepts --module. +func TestMakeCommands_ModuleFlag(t *testing.T) { + commands := []console.Command{ + &ControllerMakeCommand{}, + &MiddlewareMakeCommand{}, + &RequestMakeCommand{}, + } + + for _, cmd := range commands { + var names []string + for _, flag := range cmd.Extend().Flags { + if f, ok := flag.(*command.StringFlag); ok { + names = append(names, f.Name) + } + } + + assert.Contains(t, names, "module", cmd.Signature()) + } +} diff --git a/mail/console/module_flag_test.go b/mail/console/module_flag_test.go new file mode 100644 index 000000000..959f197ec --- /dev/null +++ b/mail/console/module_flag_test.go @@ -0,0 +1,28 @@ +package console + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" +) + +// Every make command of the package accepts --module. +func TestMakeCommands_ModuleFlag(t *testing.T) { + commands := []console.Command{ + NewMailMakeCommand(), + } + + for _, cmd := range commands { + var names []string + for _, flag := range cmd.Extend().Flags { + if f, ok := flag.(*command.StringFlag); ok { + names = append(names, f.Name) + } + } + + assert.Contains(t, names, "module", cmd.Signature()) + } +} diff --git a/modular/console/init_command_test.go b/modular/console/init_command_test.go index c55530f7d..9d88b6315 100644 --- a/modular/console/init_command_test.go +++ b/modular/console/init_command_test.go @@ -1,6 +1,7 @@ package console import ( + "os" "strings" "testing" @@ -216,3 +217,46 @@ func removeScaffold(t *testing.T) { } } } + +func (s *InitCommandTestSuite) TestWriteFailures() { + s.Run("the modules directory is a file", func() { + s.Require().NoError(file.PutContent("bootstrap/app.go", chainedApp)) + s.Require().NoError(file.PutContent("modules", "file")) + + s.Error(NewInitCommand(nil).Handle(mocksconsole.NewContext(s.T()))) + s.False(file.Exists("config/modular.go")) + removeScaffold(s.T()) + }) + + s.Run("the config directory is a file", func() { + s.Require().NoError(file.PutContent("bootstrap/app.go", chainedApp)) + s.Require().NoError(file.PutContent("config", "file")) + + ctx := mocksconsole.NewContext(s.T()) + ctx.EXPECT().TwoColumnDetail("modules/.gitkeep", statusCreated).Once() + + s.Error(NewInitCommand(nil).Handle(ctx)) + s.False(file.Exists("bootstrap/modules.go")) + removeScaffold(s.T()) + }) + + s.Run("main.go is read-only", func() { + if os.Geteuid() == 0 { + s.T().Skip("root writes read-only files") + } + s.Require().NoError(file.PutContent("bootstrap/app.go", chainedApp)) + s.Require().NoError(file.PutContent("main.go", mainFile)) + s.Require().NoError(os.Chmod("main.go", 0o444)) + s.T().Cleanup(func() { _ = os.Chmod("main.go", 0o644) }) + + ctx := mocksconsole.NewContext(s.T()) + ctx.EXPECT().TwoColumnDetail("modules/.gitkeep", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("config/modular.go", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("bootstrap/modules.go", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("bootstrap/app.go WithModules(Modules)", statusCreated).Once() + + s.Error(NewInitCommand(nil).Handle(ctx)) + s.False(file.Contain("main.go", generateDirective)) + s.Require().NoError(os.Chmod("main.go", 0o644)) + }) +} diff --git a/modular/console/module_make_command_test.go b/modular/console/module_make_command_test.go index 7dadcc33e..a400c4060 100644 --- a/modular/console/module_make_command_test.go +++ b/modular/console/module_make_command_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" + "github.com/goravel/framework/contracts/console" "github.com/goravel/framework/errors" mocksconfig "github.com/goravel/framework/mocks/config" mocksconsole "github.com/goravel/framework/mocks/console" @@ -397,3 +398,81 @@ func mustContent(t *testing.T, path string) string { return content } + +func (s *ModuleMakeCommandTestSuite) TestAsksForName() { + ctx := s.context("", false, true, false, 0) + ctx.EXPECT().Ask("Enter the module name", mock.Anything).RunAndReturn(func(_ string, options ...console.AskOption) (string, error) { + s.Require().Len(options, 1) + s.Equal(errors.ModularModuleNameEmpty, options[0].Validate("")) + s.NoError(options[0].Validate("Blog")) + + return "Blog", nil + }).Once() + ctx.EXPECT().Success("Module created successfully: modules/blog").Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + s.Equal([]string{"module.go"}, s.files("modules/blog")) +} + +func (s *ModuleMakeCommandTestSuite) TestFailures() { + // failed runs make:module and expects one error, of any text. + failed := func(name string, plain, force bool) { + ctx := s.context(name, false, plain, force, 0) + ctx.EXPECT().Error(mock.Anything).Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + } + reset := func() { + removeScaffold(s.T()) + s.Require().NoError(file.PutContent("bootstrap/app.go", chainedApp)) + } + + s.Run("blank name", func() { + ctx := s.context(" ", false, false, false, 0) + ctx.EXPECT().Error(errors.ModularModuleNameEmpty.Error()).Once() + + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + }) + + s.Run("bootstrap/modules.go is a directory", func() { + s.Require().NoError(file.PutContent("bootstrap/modules.go/keep", "")) + + failed("Blog", false, false) + s.False(file.Exists("modules/blog")) + reset() + }) + + s.Run("bootstrap/modules.go is not Go", func() { + s.Require().NoError(file.PutContent("bootstrap/modules.go", "not go\n")) + + failed("Blog", false, false) + s.False(file.Exists("modules/blog")) + reset() + }) + + s.Run("bootstrap/modules.go cannot be edited", func() { + // The imports parse, the rest does not. + s.Require().NoError(file.PutContent("bootstrap/modules.go", "package bootstrap\n\nfunc Modules( {\n")) + + failed("Blog", true, false) + s.True(file.Exists("modules/blog/module.go")) + reset() + }) + + s.Run("a stale stub cannot be removed", func() { + // --force --plain removes routes/api.go, here a directory with content. + s.Require().NoError(file.PutContent("modules/blog/routes/api.go/keep", "")) + + failed("Blog", true, true) + s.False(file.Exists("modules/blog/module.go")) + reset() + }) + + s.Run("a stub cannot be written", func() { + s.Require().NoError(file.PutContent("modules/blog/module.go/keep", "")) + + failed("Blog", true, true) + s.False(file.Exists("bootstrap/modules.go")) + reset() + }) +} diff --git a/modular/console/publish_command_test.go b/modular/console/publish_command_test.go index 252647e72..6a28a990e 100644 --- a/modular/console/publish_command_test.go +++ b/modular/console/publish_command_test.go @@ -6,6 +6,7 @@ import ( "testing" "testing/fstest" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" contractsmodular "github.com/goravel/framework/contracts/modular" @@ -147,3 +148,21 @@ func (s *PublishCommandTestSuite) TestFailureDoesNotStopOtherModules() { s.False(file.Exists("modules/billing/app.js")) s.True(file.Exists("modules/shop/app.js")) } + +func (s *PublishCommandTestSuite) TestWriteFailure() { + billing := &sourcesModule{testModule{"billing"}, fstest.MapFS{"resources/js/app.js": {Data: []byte("app")}}} + // An earlier publish owns the directory, but a file is where resources/ goes. + s.Require().NoError(file.PutContent("modules/billing/"+modular.PublishMarker, `{"module": "github.com/goravel/framework"}`)) + s.Require().NoError(file.PutContent("modules/billing/resources", "file")) + + registry := mocksmodular.NewRegistry(s.T()) + registry.EXPECT().All().Return([]contractsmodular.Module{billing}) + registry.EXPECT().Path("billing").Return("modules/billing", true) + + ctx := mocksconsole.NewContext(s.T()) + ctx.EXPECT().Option("module").Return("").Once() + ctx.EXPECT().Error(mock.Anything).Once() + ctx.EXPECT().TwoColumnDetail("billing", statusFailed).Once() + + s.Equal(errors.ModularPublishFailed.Args(1, 1), s.command(registry).Handle(ctx)) +} diff --git a/modular/lang_fs_test.go b/modular/lang_fs_test.go index 853b32dde..652424199 100644 --- a/modular/lang_fs_test.go +++ b/modular/lang_fs_test.go @@ -95,3 +95,21 @@ func TestLangFS_UnwrapsPrevious(t *testing.T) { _, err = fs.ReadFile(second, "en/shop/messages.json") assert.ErrorIs(t, err, fs.ErrNotExist) } + +func TestLangFS_Open(t *testing.T) { + fsys := NewLangFS(map[string]fs.FS{ + "blog": fstest.MapFS{"en/messages.json": {Data: []byte(`{"title": "Blog"}`)}}, + }, nil) + + opened, err := fsys.Open("en/blog/messages.json") + require.NoError(t, err) + info, err := opened.Stat() + require.NoError(t, err) + assert.Equal(t, "messages.json", info.Name()) + assert.NoError(t, opened.Close()) + + _, err = fsys.Open("en/shop/messages.json") + assert.ErrorIs(t, err, fs.ErrNotExist) + _, err = fsys.Open("../en/blog/messages.json") + assert.ErrorIs(t, err, fs.ErrInvalid) +} diff --git a/modular/prefix_fs_test.go b/modular/prefix_fs_test.go index 2e0872253..e15ad3ec2 100644 --- a/modular/prefix_fs_test.go +++ b/modular/prefix_fs_test.go @@ -2,6 +2,7 @@ package modular_test import ( "bytes" + "io" "io/fs" "testing" "testing/fstest" @@ -73,3 +74,98 @@ func TestPrefixFS_Views(t *testing.T) { require.NoError(t, tmpl.ExecuteTemplate(&out, "blog/index.tmpl", map[string]any{"title": "Blog"})) assert.Equal(t, "

Blog

", out.String()) } + +// stubFS opens file for every name, or fails with err. +type stubFS struct { + file fs.File + err error +} + +func (r stubFS) Open(string) (fs.File, error) { return r.file, r.err } + +// stubFile is a file that cannot be listed; Stat fails with err. +type stubFile struct{ err error } + +func (r stubFile) Stat() (fs.FileInfo, error) { return nil, r.err } +func (r stubFile) Read([]byte) (int, error) { return 0, io.EOF } +func (r stubFile) Close() error { return nil } + +func TestPrefixFS_Root(t *testing.T) { + fsys := modular.NewPrefixFS("blog", blogViews()) + + root, err := fsys.Open(".") + require.NoError(t, err) + t.Cleanup(func() { assert.NoError(t, root.Close()) }) + + info, err := root.Stat() + require.NoError(t, err) + assert.Equal(t, ".", info.Name()) + assert.Zero(t, info.Size()) + assert.Equal(t, fs.ModeDir|0o555, info.Mode()) + assert.True(t, info.IsDir()) + assert.Nil(t, info.Sys()) + + mount, err := fs.Stat(fsys, "blog") + require.NoError(t, err) + assert.Equal(t, mount.ModTime(), info.ModTime()) + + _, err = root.Read(make([]byte, 1)) + assert.ErrorIs(t, err, fs.ErrInvalid) + + // More entries are asked for than the single mount point. + dir, ok := root.(fs.ReadDirFile) + require.True(t, ok) + entries, err := dir.ReadDir(5) + require.NoError(t, err) + require.Len(t, entries, 1) + assert.Equal(t, "blog", entries[0].Name()) + _, err = dir.ReadDir(5) + assert.ErrorIs(t, err, io.EOF) +} + +func TestPrefixFS_InvalidPaths(t *testing.T) { + fsys := modular.NewPrefixFS("blog", blogViews()) + + _, err := fs.ReadDir(fsys, "../blog") + assert.ErrorIs(t, err, fs.ErrInvalid) + + _, err = fs.ReadFile(fsys, "../blog/index.tmpl") + assert.ErrorIs(t, err, fs.ErrInvalid) + + // Directories are not files. + for _, name := range []string{".", "blog"} { + _, err = fs.ReadFile(fsys, name) + assert.ErrorIs(t, err, fs.ErrInvalid, name) + } +} + +func TestPrefixFS_UnreadableInner(t *testing.T) { + fsys := modular.NewPrefixFS("blog", stubFS{err: fs.ErrPermission}) + + _, err := fsys.Open(".") + assert.ErrorIs(t, err, fs.ErrPermission) + _, err = fsys.Open("blog") + assert.ErrorIs(t, err, fs.ErrPermission) + _, err = fs.ReadDir(fsys, ".") + assert.ErrorIs(t, err, fs.ErrPermission) + _, err = fs.Stat(fsys, ".") + assert.ErrorIs(t, err, fs.ErrPermission) + _, err = fs.Stat(fsys, "blog") + assert.ErrorIs(t, err, fs.ErrPermission) +} + +func TestPrefixFS_MountFile(t *testing.T) { + // The inner root cannot be described or listed. + fsys := modular.NewPrefixFS("blog", stubFS{file: stubFile{err: fs.ErrPermission}}) + + mount, err := fsys.Open("blog") + require.NoError(t, err) + + _, err = mount.Stat() + assert.ErrorIs(t, err, fs.ErrPermission) + + dir, ok := mount.(fs.ReadDirFile) + require.True(t, ok) + _, err = dir.ReadDir(-1) + assert.ErrorIs(t, err, fs.ErrInvalid) +} diff --git a/modular/publish_test.go b/modular/publish_test.go index bea85b6be..adb684bb2 100644 --- a/modular/publish_test.go +++ b/modular/publish_test.go @@ -1,6 +1,7 @@ package modular import ( + "encoding/json" "io/fs" "os" "path/filepath" @@ -249,3 +250,94 @@ func TestPublish(t *testing.T) { assert.Equal(t, PublishNone, PublishState(onlyGo, dependency, t.TempDir())) }) } + +// unreadableFS lists inner but fails to open the file named fail. +type unreadableFS struct { + inner fs.FS + fail string +} + +func (r unreadableFS) Open(name string) (fs.File, error) { + if name == r.fail { + return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrPermission} + } + + return r.inner.Open(name) +} + +func TestPublish_Failures(t *testing.T) { + dependency := Dependency{Path: "github.com/acme/billing", Version: "v1.2.0"} + app := fstest.MapFS{"resources/js/app.js": {Data: []byte("app")}} + // An owned directory: published before, so Publish may write into it. + owned := func(t *testing.T, files ...string) string { + dir := t.TempDir() + content, err := json.Marshal(publishMarker{Dependency: dependency, Files: files}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, PublishMarker), content, 0o644)) + + return dir + } + + t.Run("unreadable sources", func(t *testing.T) { + module := &sourcesModule{ + plainModule: plainModule{"billing"}, + sources: unreadableFS{inner: app, fail: "resources/js/app.js"}, + } + + _, err := Publish(module, dependency, t.TempDir()) + assert.ErrorIs(t, err, fs.ErrPermission) + assert.Equal(t, PublishNone, PublishState(module, dependency, t.TempDir())) + }) + + t.Run("unreadable views", func(t *testing.T) { + module := &sourcesModule{ + plainModule: plainModule{"billing"}, + views: unreadableFS{inner: fstest.MapFS{"index.tmpl": {Data: []byte("index")}}, fail: "index.tmpl"}, + } + + _, err := Publish(module, dependency, t.TempDir()) + assert.ErrorIs(t, err, fs.ErrPermission) + }) + + t.Run("a file is in the way of a directory", func(t *testing.T) { + dir := owned(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, "resources"), []byte("file"), 0o644)) + + _, err := Publish(&sourcesModule{plainModule: plainModule{"billing"}, sources: app}, dependency, dir) + assert.Error(t, err) + }) + + t.Run("a directory is in the way of a file", func(t *testing.T) { + dir := owned(t) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "resources", "js", "app.js"), 0o755)) + + _, err := Publish(&sourcesModule{plainModule: plainModule{"billing"}, sources: app}, dependency, dir) + assert.Error(t, err) + }) + + t.Run("a dropped file became a directory with content", func(t *testing.T) { + dir := owned(t, "legacy") + require.NoError(t, os.MkdirAll(filepath.Join(dir, "legacy"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "legacy", "mine.txt"), []byte("mine"), 0o644)) + + _, err := Publish(&sourcesModule{plainModule: plainModule{"billing"}, sources: app}, dependency, dir) + assert.Error(t, err) + assert.FileExists(t, filepath.Join(dir, "legacy", "mine.txt")) + }) + + t.Run("marker paths outside the directory are ignored", func(t *testing.T) { + parent := t.TempDir() + dir := filepath.Join(parent, "billing") + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(parent, "outside.txt"), []byte("keep"), 0o644)) + content, err := json.Marshal(publishMarker{Dependency: dependency, Files: []string{"../outside.txt", "."}}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, PublishMarker), content, 0o644)) + + changed, err := Publish(&sourcesModule{plainModule: plainModule{"billing"}, sources: app}, dependency, dir) + require.NoError(t, err) + assert.True(t, changed) + assert.FileExists(t, filepath.Join(parent, "outside.txt")) + assert.DirExists(t, dir) + }) +} diff --git a/modular/registry_test.go b/modular/registry_test.go index 2bf84c584..61f5968d5 100644 --- a/modular/registry_test.go +++ b/modular/registry_test.go @@ -1,6 +1,7 @@ package modular import ( + "runtime/debug" "testing" "github.com/stretchr/testify/assert" @@ -126,3 +127,32 @@ func TestProviding(t *testing.T) { assert.Equal(t, []modular.ProvidesRoutes{routed}, Providing[modular.ProvidesRoutes](modules)) assert.Empty(t, Providing[modular.ProvidesViews](modules)) } + +func TestDefault(t *testing.T) { + t.Cleanup(func() { SetDefault(nil) }) + assert.Nil(t, Default()) + + registry := NewRegistry("") + SetDefault(registry) + assert.Same(t, registry, Default()) +} + +func TestLocal(t *testing.T) { + // A module that sets its own path is in the application. + assert.True(t, Local(&locatedModule{plainModule{"blog"}, "modules/blog"})) + + info, ok := debug.ReadBuildInfo() + require.True(t, ok) + _, thirdParty := DependencyOf(&plainModule{"blog"}, info) + assert.Equal(t, !thirdParty, Local(&plainModule{"blog"})) +} + +func TestCompareOrder(t *testing.T) { + assert.Equal(t, -1, compareOrder(1, 2)) + assert.Equal(t, 1, compareOrder(2, 1)) + assert.Equal(t, 0, compareOrder(2, 2)) + assert.Equal(t, -1, compareOrder(-2, -1)) + assert.Equal(t, 1, compareOrder(-1, -2)) + assert.Equal(t, -1, compareOrder(5, -1), "negative orders go last") + assert.Equal(t, 1, compareOrder(-1, 0)) +} diff --git a/modular/static_test.go b/modular/static_test.go index 029727bab..be086589f 100644 --- a/modular/static_test.go +++ b/modular/static_test.go @@ -98,3 +98,19 @@ func TestCleanPrefix(t *testing.T) { assert.Equal(t, tt.expected, cleaned, tt.prefix) } } + +// statFailFS opens files that cannot be described. +type statFailFS struct{} + +func (statFailFS) Open(string) (fs.File, error) { return statFailFile{}, nil } + +type statFailFile struct{} + +func (statFailFile) Stat() (fs.FileInfo, error) { return nil, fs.ErrPermission } +func (statFailFile) Read([]byte) (int, error) { return 0, fs.ErrPermission } +func (statFailFile) Close() error { return nil } + +func TestNoListingFS_StatFails(t *testing.T) { + _, err := noListingFS{statFailFS{}}.Open("robots.txt") + assert.ErrorIs(t, err, fs.ErrPermission) +} diff --git a/packages/modify/utils_test.go b/packages/modify/utils_test.go index 562461dd9..87395af24 100644 --- a/packages/modify/utils_test.go +++ b/packages/modify/utils_test.go @@ -3,6 +3,7 @@ package modify import ( "bytes" "go/token" + "os" "path/filepath" "strconv" "strings" @@ -5143,3 +5144,138 @@ func (m *Module) Jobs() []queue.Job { `)) assert.Error(t, AddToModule(dir, "Jobs", "goravel/modules/blog/jobs", "&jobs.Notify{}")) } + +func TestModules_Failures(t *testing.T) { + bootstrapDir := support.Config.Paths.Bootstrap + appFile := filepath.Join(bootstrapDir, "app.go") + modulesFile := filepath.Join(bootstrapDir, "modules.go") + defer func() { + assert.NoError(t, supportfile.Remove(bootstrapDir)) + }() + + // Without bootstrap/app.go there is nothing to wire. + assert.Error(t, InitModules()) + assert.False(t, supportfile.Exists(modulesFile)) + + // A file that is not Go cannot be edited, and no modules.go is left behind. + assert.NoError(t, supportfile.PutContent(appFile, "package bootstrap\n\nfunc Boot() { return foundation.Setup().Start( }\n")) + assert.Error(t, InitModules()) + assert.False(t, supportfile.Exists(modulesFile)) + + // AddModule creates modules.go and wires it when neither exists. + assert.NoError(t, supportfile.PutContent(appFile, `package bootstrap + +import ( + contractsfoundation "github.com/goravel/framework/contracts/foundation" + "github.com/goravel/framework/foundation" +) + +func Boot() contractsfoundation.Application { + return foundation.Setup().Start() +} +`)) + assert.NoError(t, AddModule("goravel/modules/blog", "&blog.Module{}")) + assert.True(t, supportfile.Contain(appFile, "WithModules(Modules)")) + assert.True(t, supportfile.Contain(modulesFile, "&blog.Module{},")) +} + +func TestAddToModule_Failures(t *testing.T) { + defer func() { + assert.NoError(t, supportfile.Remove("modules")) + }() + add := func(dir string) error { + return AddToModule(dir, "Jobs", "goravel/"+dir+"/jobs", "&jobs.Notify{}") + } + + // The directory name is not a valid glob pattern. + assert.ErrorIs(t, add("modules/[blog"), filepath.ErrBadPattern) + + // Test files are skipped, so their Name() does not make a module. + assert.NoError(t, supportfile.PutContent("modules/tested/module_test.go", `package tested + +type Module struct{} + +func (m *Module) Name() string { + return "tested" +} +`)) + assert.ErrorIs(t, add("modules/tested"), errors.PackageModuleStructNotFound) + + // Only methods of a plain named type are considered. + assert.NoError(t, supportfile.PutContent("modules/generic/module.go", `package generic + +type Module[T any] struct{} + +func (m *Module[T]) Name() string { + return "generic" +} +`)) + assert.ErrorIs(t, add("modules/generic"), errors.PackageModuleStructNotFound) + + // A file that does not parse stops the search. + assert.NoError(t, supportfile.PutContent("modules/broken/module.go", "package broken\n\nfunc (m *Module) Name( {\n")) + assert.Error(t, add("modules/broken")) +} + +func TestAddToModule_AliasedImport(t *testing.T) { + dir := "modules/blog" + moduleFile := filepath.Join(dir, "module.go") + defer func() { + assert.NoError(t, supportfile.Remove("modules")) + }() + + assert.NoError(t, supportfile.PutContent(moduleFile, `package blog + +type Module struct{} + +func (m *Module) Name() string { + return "blog" +} +`)) + assert.NoError(t, AddToModule(dir, "Jobs", "blogjobs goravel/modules/blog/jobs", "&blogjobs.Notify{}")) + + content, err := supportfile.GetContent(moduleFile) + assert.NoError(t, err) + assert.Equal(t, `package blog + +import ( + "github.com/goravel/framework/contracts/queue" + + blogjobs "goravel/modules/blog/jobs" +) + +type Module struct{} + +func (m *Module) Name() string { + return "blog" +} + +func (m *Module) Jobs() []queue.Job { + return []queue.Job{ + &blogjobs.Notify{}, + } +} +`, content) +} + +func TestAddToModule_ReadOnlyFile(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root writes read-only files") + } + + dir := "modules/blog" + moduleFile := filepath.Join(dir, "module.go") + source := "package blog\n\ntype Module struct{}\n\nfunc (m *Module) Name() string {\n\treturn \"blog\"\n}\n" + defer func() { + assert.NoError(t, os.Chmod(moduleFile, 0o644)) + assert.NoError(t, supportfile.Remove("modules")) + }() + + assert.NoError(t, supportfile.PutContent(moduleFile, source)) + assert.NoError(t, os.Chmod(moduleFile, 0o444)) + + assert.Error(t, AddToModule(dir, "Jobs", "goravel/modules/blog/jobs", "&jobs.Notify{}")) + content, err := supportfile.GetContent(moduleFile) + assert.NoError(t, err) + assert.Equal(t, source, content) +} diff --git a/queue/console/module_flag_test.go b/queue/console/module_flag_test.go new file mode 100644 index 000000000..193d108b0 --- /dev/null +++ b/queue/console/module_flag_test.go @@ -0,0 +1,28 @@ +package console + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" +) + +// Every make command of the package accepts --module. +func TestMakeCommands_ModuleFlag(t *testing.T) { + commands := []console.Command{ + &JobMakeCommand{}, + } + + for _, cmd := range commands { + var names []string + for _, flag := range cmd.Extend().Flags { + if f, ok := flag.(*command.StringFlag); ok { + names = append(names, f.Name) + } + } + + assert.Contains(t, names, "module", cmd.Signature()) + } +} diff --git a/support/console/console_test.go b/support/console/console_test.go index 3443680fd..6a805275b 100644 --- a/support/console/console_test.go +++ b/support/console/console_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" contractsmodular "github.com/goravel/framework/contracts/modular" "github.com/goravel/framework/errors" mocksconsole "github.com/goravel/framework/mocks/console" @@ -379,3 +380,11 @@ func TestTwoColumnDetail(t *testing.T) { }) } } + +func TestModuleFlag(t *testing.T) { + moduleFlag, ok := ModuleFlag().(*command.StringFlag) + + assert.True(t, ok) + assert.Equal(t, "module", moduleFlag.Name) + assert.NotEmpty(t, moduleFlag.Usage) +} diff --git a/validation/console/module_flag_test.go b/validation/console/module_flag_test.go new file mode 100644 index 000000000..262bdf7bd --- /dev/null +++ b/validation/console/module_flag_test.go @@ -0,0 +1,29 @@ +package console + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" +) + +// Every make command of the package accepts --module. +func TestMakeCommands_ModuleFlag(t *testing.T) { + commands := []console.Command{ + &FilterMakeCommand{}, + &RuleMakeCommand{}, + } + + for _, cmd := range commands { + var names []string + for _, flag := range cmd.Extend().Flags { + if f, ok := flag.(*command.StringFlag); ok { + names = append(names, f.Name) + } + } + + assert.Contains(t, names, "module", cmd.Signature()) + } +} diff --git a/view/console/view_make_command_test.go b/view/console/view_make_command_test.go index 925ce1035..1af14519f 100644 --- a/view/console/view_make_command_test.go +++ b/view/console/view_make_command_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/goravel/framework/contracts/console/command" + "github.com/goravel/framework/errors" mocksconsole "github.com/goravel/framework/mocks/console" "github.com/goravel/framework/modular" "github.com/goravel/framework/support/file" @@ -157,3 +158,17 @@ func (s *ViewMakeCommandTestSuite) TestModule() { s.True(file.Contain(filepath.Join("modules", "blog", "resources", "views", "posts", "index.tmpl"), `{{ define "blog/posts/index.tmpl" }}`)) s.NoDirExists("resources") } + +func (s *ViewMakeCommandTestSuite) TestModuleNotFound() { + modular.SetDefault(modular.NewRegistry("")) + s.T().Cleanup(func() { modular.SetDefault(nil) }) + + mockContext := mocksconsole.NewContext(s.T()) + mockContext.EXPECT().Arguments().Return([]string{"posts/index"}).Once() + mockContext.EXPECT().Option("module").Return("shop").Once() + mockContext.EXPECT().Error(errors.ModularModuleNotFound.Args("shop").Error()).Once() + + s.Nil((&ViewMakeCommand{}).Handle(mockContext)) + s.NoDirExists("resources") + s.NoDirExists("modules") +} diff --git a/vite/vite_test.go b/vite/vite_test.go index d38e27285..5ec724b6f 100644 --- a/vite/vite_test.go +++ b/vite/vite_test.go @@ -713,3 +713,16 @@ func TestIsCSS(t *testing.T) { assert.False(t, isCSS(name), name) } } + +func TestHotFile_InvalidJSON(t *testing.T) { + public := setupProject(t) + vite := newTestVite(t, "") + + // The file exists, but names no dev server. + for _, content := range []string{`{"url": `, `{"aliases": {"@blog": "modules/blog/resources/js"}}`} { + writeFile(t, filepath.Join(public, "hot"), content) + + assert.True(t, vite.IsRunningHot()) + assert.Empty(t, vite.ReactRefresh(), content) + } +} From 6690c1ce23d9515f82001e8f6539b5b5e6fc382d Mon Sep 17 00:00:00 2001 From: Saad Date: Fri, 13 Nov 2026 06:30:21 +0600 Subject: [PATCH 22/29] fix(database): fall back to the framework facades in migration stubs make:migration imported /app/facades whenever bootstrap/app.go calls foundation.Setup(). An application without that package, or without its Schema facade, got a migration that does not compile, and since artisan runs through `go run .` every later artisan command failed as well. The stub now uses the app's facades only when app/facades/schema.go exists, and github.com/goravel/framework/facades otherwise. The package name falls back with the import, so an app whose facades package has another name does not get facades.Schema() from one package and f.Schema() from the other. --- database/migration/migrator.go | 9 ++++---- database/migration/migrator_test.go | 33 +++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/database/migration/migrator.go b/database/migration/migrator.go index 4ee9ee82b..67f471108 100644 --- a/database/migration/migrator.go +++ b/database/migration/migrator.go @@ -61,9 +61,10 @@ func (r *Migrator) CreateIn(dir string, name string, modelName string) (string, // Prepend timestamp to the file name. fileName := r.creator.GetFileName(name) - facadesImport := packages.Paths().Facades().Import() - if !env.IsBootstrapSetup() { - facadesImport = "github.com/goravel/framework/facades" + // The app's facades only when its Schema facade exists, else the generated file would not compile. + facadesImport, facadesPackage := packages.Paths().Facades().Import(), packages.Paths().Facades().Package() + if !env.IsBootstrapSetup() || !supportfile.Exists(path.Facade("schema.go")) { + facadesImport, facadesPackage = "github.com/goravel/framework/facades", "facades" } filePath, pkg := r.creator.GetPath(fileName), packages.Paths().Migrations().Package() @@ -72,7 +73,7 @@ func (r *Migrator) CreateIn(dir string, name string, modelName string) (string, } templateData := StubData{ - FacadesPackage: packages.Paths().Facades().Package(), + FacadesPackage: facadesPackage, FacadesImport: facadesImport, Package: pkg, SchemaFields: schemaFields, diff --git a/database/migration/migrator_test.go b/database/migration/migrator_test.go index edff86e2e..ef96e392e 100644 --- a/database/migration/migrator_test.go +++ b/database/migration/migrator_test.go @@ -18,6 +18,7 @@ import ( mocksmigration "github.com/goravel/framework/mocks/database/migration" mocksorm "github.com/goravel/framework/mocks/database/orm" mocksschema "github.com/goravel/framework/mocks/database/schema" + "github.com/goravel/framework/support" "github.com/goravel/framework/support/carbon" "github.com/goravel/framework/support/color" "github.com/goravel/framework/support/file" @@ -81,6 +82,38 @@ func (s *MigratorSuite) TestCreate() { s.False(file.Exists(filepath.Join(path, fileName+".go"))) }) + s.Run("facades import", func() { + defer func() { + s.NoError(file.Remove("bootstrap")) + s.NoError(file.Remove("app")) + s.NoError(file.Remove("modules")) + }() + s.NoError(file.PutContent(filepath.Join("bootstrap", "app.go"), "package bootstrap\n\nfunc Boot() { foundation.Setup().Create() }\n")) + + // Without the app's Schema facade the generated file falls back to the framework's facades. + fileName, err := s.migrator.CreateIn("modules/blog/database/migrations", "create_posts_table", "") + s.NoError(err) + migration := filepath.Join(pwd, "modules", "blog", "database", "migrations", fileName+".go") + s.True(file.Contain(migration, `"github.com/goravel/framework/facades"`)) + s.NoError(file.Remove(migration)) + + // The package name falls back with the import, also when the app's is named differently. + facadesPath := support.Config.Paths.Facades + support.Config.Paths.Facades = "app/support/f" + fileName, err = s.migrator.CreateIn("modules/blog/database/migrations", "create_posts_table", "") + support.Config.Paths.Facades = facadesPath + s.NoError(err) + migration = filepath.Join(pwd, "modules", "blog", "database", "migrations", fileName+".go") + s.True(file.Contain(migration, "facades.Schema()")) + s.False(file.Contain(migration, "f.Schema()")) + s.NoError(file.Remove(migration)) + + s.NoError(file.PutContent(filepath.Join("app", "facades", "schema.go"), "package facades\n")) + fileName, err = s.migrator.CreateIn("modules/blog/database/migrations", "create_posts_table", "") + s.NoError(err) + s.True(file.Contain(filepath.Join(pwd, "modules", "blog", "database", "migrations", fileName+".go"), `/app/facades"`)) + }) + s.Run("model not found", func() { s.mockSchema.EXPECT().GetModel("NonExistentModel").Return(nil).Once() From abc4c4980baad62f3df1aa1d6573bd506b25f986 Mon Sep 17 00:00:00 2001 From: Saad Date: Fri, 13 Nov 2026 06:31:52 +0600 Subject: [PATCH 23/29] fix(modular): require kebab-case module names Module.Name() is documented as kebab case, but Register only rejected duplicates. The name becomes the config key modules., the view and translation prefix and the Vite alias @, so "blog.admin" landed under modules.blog, "" mounted views at the root where none resolve, and "Blog" failed later in vite:inputs. Register now fails with ModularModuleNameNotKebab. modular.ValidName holds the pattern make:module already used. modular:publish --module also tries the kebab-case name, like the --module option of the make:* commands: --module BlogPosts finds blog-posts. --- errors/list.go | 1 + modular/console/module_make_command.go | 6 +++--- modular/console/publish_command.go | 5 +++++ modular/console/publish_command_test.go | 13 +++++++++++-- modular/registry.go | 12 ++++++++++++ modular/registry_test.go | 10 ++++++++++ 6 files changed, 42 insertions(+), 5 deletions(-) diff --git a/errors/list.go b/errors/list.go index e50ed5202..28c6de469 100644 --- a/errors/list.go +++ b/errors/list.go @@ -226,6 +226,7 @@ var ( ModularModuleNameEmpty = New("the module name cannot be empty").SetModule(ModuleModular) ModularModuleNameForbidden = New("module name %q is reserved, Go cannot import a package named %q").SetModule(ModuleModular) ModularModuleNameInvalid = New("module name %q is invalid, use letters, digits and dashes, starting with a letter").SetModule(ModuleModular) + ModularModuleNameNotKebab = New("module %T is named %q, a module name is kebab case like blog or blog-posts").SetModule(ModuleModular) ModularModuleNotFound = New("module %s is not registered").SetModule(ModuleModular) ModularModuleNotLocal = New("module %s belongs to another Go module, files can only be generated in the application's own modules").SetModule(ModuleModular) ModularModuleNotPublished = New("module %s (%s) is not published to %s, run `go generate` or `artisan modular:publish`").SetModule(ModuleModular) diff --git a/modular/console/module_make_command.go b/modular/console/module_make_command.go index c512d800c..270eb0692 100644 --- a/modular/console/module_make_command.go +++ b/modular/console/module_make_command.go @@ -12,6 +12,7 @@ import ( "github.com/goravel/framework/contracts/console" "github.com/goravel/framework/contracts/console/command" "github.com/goravel/framework/errors" + "github.com/goravel/framework/modular" "github.com/goravel/framework/packages" "github.com/goravel/framework/packages/modify" "github.com/goravel/framework/support" @@ -23,9 +24,8 @@ import ( ) var ( - // moduleInputPattern is what make:module accepts, moduleNamePattern the kebab name it derives. + // moduleInputPattern is what make:module accepts; the kebab name it derives must pass modular.ValidName. moduleInputPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9 _-]*$`) - moduleNamePattern = regexp.MustCompile(`^[a-z][a-z0-9]*(-[a-z0-9]+)*$`) // reservedModulePackages are packages Go refuses to import. reservedModulePackages = map[string]bool{"main": true, "init": true, "internal": true, "vendor": true} @@ -192,7 +192,7 @@ func newModuleStubs(name, directory string) (*moduleStubs, error) { kebab := str.Of(name).Kebab().String() pkg := strings.ReplaceAll(kebab, "-", "") - if !moduleInputPattern.MatchString(name) || !moduleNamePattern.MatchString(kebab) { + if !moduleInputPattern.MatchString(name) || !modular.ValidName(kebab) { return nil, errors.ModularModuleNameInvalid.Args(name) } if token.IsKeyword(pkg) || reservedModulePackages[pkg] { diff --git a/modular/console/publish_command.go b/modular/console/publish_command.go index 236f55de8..194a9c910 100644 --- a/modular/console/publish_command.go +++ b/modular/console/publish_command.go @@ -10,6 +10,7 @@ import ( "github.com/goravel/framework/errors" "github.com/goravel/framework/modular" "github.com/goravel/framework/support/path" + "github.com/goravel/framework/support/str" ) type PublishCommand struct { @@ -53,7 +54,11 @@ func (r *PublishCommand) Handle(ctx console.Context) error { modules := r.registry.All() if name := ctx.Option("module"); name != "" { + // As given, then in kebab case, like the --module option of make:*. module, ok := r.registry.Find(name) + if !ok { + module, ok = r.registry.Find(str.Of(name).Kebab().String()) + } if !ok { return errors.ModularModuleNotFound.Args(name) } diff --git a/modular/console/publish_command_test.go b/modular/console/publish_command_test.go index 6a28a990e..7deeadd28 100644 --- a/modular/console/publish_command_test.go +++ b/modular/console/publish_command_test.go @@ -111,8 +111,10 @@ func (s *PublishCommandTestSuite) TestModuleOption() { registry := mocksmodular.NewRegistry(s.T()) registry.EXPECT().All().Return([]contractsmodular.Module{billing, &testModule{"plain"}}) - registry.EXPECT().Find("billing").Return(billing, true).Once() - registry.EXPECT().Find("shop").Return(nil, false).Once() + registry.EXPECT().Find("billing").Return(billing, true).Twice() + registry.EXPECT().Find("Billing").Return(nil, false).Once() + // Not found as given nor in kebab case, which is the same name here. + registry.EXPECT().Find("shop").Return(nil, false).Twice() registry.EXPECT().Path("billing").Return("modules/billing", true) ctx := mocksconsole.NewContext(s.T()) @@ -121,6 +123,13 @@ func (s *PublishCommandTestSuite) TestModuleOption() { s.NoError(s.command(registry).Handle(ctx)) + // The kebab-case name is tried next, like make:* --module does. + ctx = mocksconsole.NewContext(s.T()) + ctx.EXPECT().Option("module").Return("Billing").Once() + ctx.EXPECT().TwoColumnDetail("billing", statusUnchanged).Once() + + s.NoError(s.command(registry).Handle(ctx)) + ctx = mocksconsole.NewContext(s.T()) ctx.EXPECT().Option("module").Return("shop").Once() diff --git a/modular/registry.go b/modular/registry.go index c93ec69d2..8692cd6ae 100644 --- a/modular/registry.go +++ b/modular/registry.go @@ -3,6 +3,7 @@ package modular import ( + "regexp" "runtime/debug" "slices" "sync" @@ -15,6 +16,14 @@ import ( var _ modular.Registry = (*Registry)(nil) +// namePattern is a kebab-case module name, e.g. "blog" or "blog-posts". +var namePattern = regexp.MustCompile(`^[a-z][a-z0-9]*(-[a-z0-9]+)*$`) + +// ValidName reports whether name is a kebab-case module name. +func ValidName(name string) bool { + return namePattern.MatchString(name) +} + var ( defaultMu sync.RWMutex defaultRegistry modular.Registry @@ -79,6 +88,9 @@ func (r *Registry) Register(modules ...modular.Module) error { } name := module.Name() + if !ValidName(name) { + return errors.ModularModuleNameNotKebab.Args(module, name) + } if _, ok := r.byName[name]; ok { return errors.ModularModuleDuplicate.Args(name) } diff --git a/modular/registry_test.go b/modular/registry_test.go index 61f5968d5..dd26e93b8 100644 --- a/modular/registry_test.go +++ b/modular/registry_test.go @@ -69,6 +69,16 @@ func TestRegistry_Register(t *testing.T) { assert.Equal(t, []string{"blog", "shop"}, names(registry.All())) } +func TestRegistry_RegisterInvalidName(t *testing.T) { + for _, name := range []string{"", "Blog", "blog.admin", "blog_posts", "-blog", "blog--posts", "1blog"} { + err := NewRegistry("").Register(&plainModule{name}) + assert.ErrorIs(t, err, errors.ModularModuleNameNotKebab, name) + } + assert.EqualError(t, NewRegistry("").Register(&plainModule{"Blog"}), + `[modular] module *modular.plainModule is named "Blog", a module name is kebab case like blog or blog-posts`) + assert.NoError(t, NewRegistry("").Register(&plainModule{"blog-posts2"})) +} + func TestRegistry_Path(t *testing.T) { registry := NewRegistry("") require.NoError(t, registry.Register(&plainModule{"blog"}, &locatedModule{plainModule{"shop"}, "app/shop"})) From 16bf75a175fbda33313f4f6d972af6c67e6286c3 Mon Sep 17 00:00:00 2001 From: Saad Date: Fri, 13 Nov 2026 06:35:03 +0600 Subject: [PATCH 24/29] fix(modular): keep edited stubs on make:module --force Switching variants with --force (e.g. --plain on a full module) removed every stub path the new variant does not write, including files the user had edited since: controllers, providers, routes, views. A stale stub is now removed only when its content is still that of a generated variant (full, --api or --plain). Edited files are kept and reported with a warning. --- modular/console/module_make_command.go | 53 ++++++++++++++++----- modular/console/module_make_command_test.go | 17 +++++++ 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/modular/console/module_make_command.go b/modular/console/module_make_command.go index 270eb0692..a7b3d45be 100644 --- a/modular/console/module_make_command.go +++ b/modular/console/module_make_command.go @@ -3,9 +3,11 @@ package console import ( "go/parser" "go/token" + "maps" "os" "path/filepath" "regexp" + "slices" "strings" "github.com/goravel/framework/contracts/config" @@ -124,7 +126,16 @@ func (r *ModuleMakeCommand) makeOne(ctx console.Context, name string) error { } files := stubs.files(ctx.OptionBool("api"), ctx.OptionBool("plain")) - if err := removeStaleStubs(root, stubs.files(false, false), files); err != nil { + kept, err := removeStaleStubs( + root, + []map[string]string{ + stubs.files(false, false), + stubs.files(true, false), + stubs.files(false, true), + }, + files, + ) + if err != nil { return err } for name, content := range files { @@ -140,6 +151,9 @@ func (r *ModuleMakeCommand) makeOne(ctx console.Context, name string) error { } ctx.Success("Module created successfully: " + stubs.dir) + for _, file := range kept { + ctx.Warning("Kept " + stubs.dir + "/" + file + ": it was edited, remove it if the module no longer needs it") + } // An assigned chain (builder := foundation.Setup()...) cannot be edited: the user wires it. if !file.Contain(path.Bootstrap("app.go"), "WithModules") { @@ -149,27 +163,42 @@ func (r *ModuleMakeCommand) makeOne(ctx console.Context, name string) error { return nil } -// removeStaleStubs deletes the stubs of an earlier --force run that this one no longer writes. -func removeStaleStubs(root string, all, current map[string]string) error { - for name := range all { - if _, ok := current[name]; ok { - continue +// removeStaleStubs deletes the stubs of an earlier --force run that this one no longer writes, +// unless they were edited; it returns the names of the edited ones it kept. +func removeStaleStubs(root string, variants []map[string]string, current map[string]string) ([]string, error) { + stubs := make(map[string][]string) + for _, variant := range variants { + for name, content := range variant { + if _, ok := current[name]; !ok { + stubs[name] = append(stubs[name], content) + } } + } + var kept []string + for _, name := range slices.Sorted(maps.Keys(stubs)) { stale := filepath.Join(root, filepath.FromSlash(name)) - if err := os.Remove(stale); err != nil { - if os.IsNotExist(err) { - continue - } + content, err := os.ReadFile(stale) + if os.IsNotExist(err) { + continue + } + if err != nil { + return nil, err + } + if !slices.Contains(stubs[name], string(content)) { + kept = append(kept, name) + continue + } - return err + if err := os.Remove(stale); err != nil { + return nil, err } // Remove the directories the stub leaves empty; os.Remove refuses the others. for dir := filepath.Dir(stale); dir != root && os.Remove(dir) == nil; dir = filepath.Dir(dir) { } } - return nil + return kept, nil } // moduleStubs holds the names derived from a module name, e.g. "BlogPosts". diff --git a/modular/console/module_make_command_test.go b/modular/console/module_make_command_test.go index a400c4060..81d3c21a2 100644 --- a/modular/console/module_make_command_test.go +++ b/modular/console/module_make_command_test.go @@ -308,6 +308,23 @@ func (s *ModuleMakeCommandTestSuite) TestForceRemovesStaleStubs() { s.Equal(1, strings.Count(modules, "&blog.Module{},")) } +// --force keeps a stale stub that was edited and warns about it. +func (s *ModuleMakeCommandTestSuite) TestForceKeepsEditedStubs() { + ctx := s.context("Blog", false, false, false, 0) + ctx.EXPECT().Success("Module created successfully: modules/blog").Once() + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + s.Require().NoError(file.PutContent("modules/blog/routes/web.go", "package routes\n\n// edited\n")) + s.Require().NoError(file.PutContent("modules/blog/http/controllers/blog_controller.go", "package controllers\n")) + + ctx = s.context("Blog", false, true, true, 0) + ctx.EXPECT().Success("Module created successfully: modules/blog").Once() + ctx.EXPECT().Warning("Kept modules/blog/http/controllers/blog_controller.go: it was edited, remove it if the module no longer needs it").Once() + ctx.EXPECT().Warning("Kept modules/blog/routes/web.go: it was edited, remove it if the module no longer needs it").Once() + s.NoError(NewModuleMakeCommand(nil).Handle(ctx)) + + s.ElementsMatch([]string{"module.go", "routes/web.go", "http/controllers/blog_controller.go"}, s.files("modules/blog")) +} + func (s *ModuleMakeCommandTestSuite) TestErrors() { s.Run("exists without force", func() { s.Require().NoError(file.PutContent("modules/blog/module.go", "package blog\n")) From 5904ce73273e25bb5c38155f3797eaa302a21f7e Mon Sep 17 00:00:00 2001 From: Saad Date: Fri, 13 Nov 2026 06:36:50 +0600 Subject: [PATCH 25/29] fix(foundation): do not change the WithEvents map when adding module listeners configureEventListeners appended module listeners to the map WithEvents returned. When that function returns the same map on every call, each Build() (Restart rebuilds) added the module listeners again, so they fired two, three, ... times; append could also write into the app's slices. The listeners are now merged into a copy with slices.Concat. --- foundation/application.go | 12 +++++------- foundation/application_modules_test.go | 13 +++++++++++-- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/foundation/application.go b/foundation/application.go index a736b6a68..db85509ac 100644 --- a/foundation/application.go +++ b/foundation/application.go @@ -556,17 +556,15 @@ func (r *Application) configureModuleConfig() { } func (r *Application) configureEventListeners() { - var eventToListeners map[event.Event][]event.Listener + // A copy: WithEvents may return the same map on every build. + eventToListeners := make(map[event.Event][]event.Listener) if r.builder.eventToListeners != nil { - eventToListeners = r.builder.eventToListeners() + maps.Copy(eventToListeners, r.builder.eventToListeners()) } for _, module := range modular.Providing[contractsmodular.ProvidesEvents](r.modules) { - if eventToListeners == nil { - eventToListeners = make(map[event.Event][]event.Listener) - } - // Append, so shared events keep every listener. + // Concat keeps every listener of a shared event and never writes into the app's slice. for e, listeners := range module.Events() { - eventToListeners[e] = append(eventToListeners[e], listeners...) + eventToListeners[e] = slices.Concat(eventToListeners[e], listeners) } } diff --git a/foundation/application_modules_test.go b/foundation/application_modules_test.go index 7f2fe6950..10c8ca049 100644 --- a/foundation/application_modules_test.go +++ b/foundation/application_modules_test.go @@ -373,18 +373,27 @@ func (s *ApplicationTestSuite) TestConfigureEventListeners_Modules() { mockEvent.EXPECT().Register(map[event.Event][]event.Listener{ shared: {appListener, moduleListener}, moduleOnly: {otherListener}, - }).Return().Once() + }).Return().Twice() s.app.Instance(binding.Event, mockEvent) + // One map for every build, its slice with spare capacity: neither may be written to. + appListeners := make([]event.Listener, 1, 4) + appListeners[0] = appListener + appEvents := map[event.Event][]event.Listener{shared: appListeners} + builder := NewApplicationBuilder(s.app) builder.eventToListeners = func() map[event.Event][]event.Listener { - return map[event.Event][]event.Listener{shared: {appListener}} + return appEvents } s.withModules(builder, &richModule{testModule: testModule{"blog"}, events: map[event.Event][]event.Listener{ shared: {moduleListener}, moduleOnly: {otherListener}, }}) s.app.configureEventListeners() + s.app.configureEventListeners() + + s.Equal(map[event.Event][]event.Listener{shared: {appListener}}, appEvents) + s.Equal([]event.Listener{appListener, nil}, appListeners[:2]) } func (s *ApplicationTestSuite) TestConfigureRoutes_Modules() { From 395c57c17670562e36f102856dee049fb42f934a Mon Sep 17 00:00:00 2001 From: Saad Date: Fri, 13 Nov 2026 06:37:51 +0600 Subject: [PATCH 26/29] fix(vite): reject module Vite entries outside the module vite:inputs checked module aliases with CleanRelative but joined ViteEntries onto the module directory as given, so "../../x.js" or "/x.js" reached Vite as a build input outside the module. Entries now go through the same check and fail with ModularViteEntryInvalid; "" and "." are rejected too. --- errors/list.go | 1 + vite/console/inputs_command.go | 7 ++++++- vite/console/inputs_command_modules_test.go | 13 +++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/errors/list.go b/errors/list.go index 28c6de469..b5f906c7d 100644 --- a/errors/list.go +++ b/errors/list.go @@ -245,6 +245,7 @@ var ( ModularViewsFSRequired = New("module %s returned a nil views filesystem").SetModule(ModuleModular) ModularViteAliasDuplicate = New("vite alias %s of module %s is already used by module %s").SetModule(ModuleModular) ModularViteAliasInvalid = New("module %s: vite alias %q -> %q is invalid, use a key like @name and a relative directory inside the module").SetModule(ModuleModular) + ModularViteEntryInvalid = New("module %s: vite entry %q is invalid, use a relative file path inside the module").SetModule(ModuleModular) NotificationChannelNotFound = New("notification channel not found: %s").SetModule(ModuleNotification) NotificationChannelNotQueueable = New("notification channel %q does not support queued dispatch (does not implement ResolvableChannel)").SetModule(ModuleNotification) diff --git a/vite/console/inputs_command.go b/vite/console/inputs_command.go index 18c71e7cf..ff3daf361 100644 --- a/vite/console/inputs_command.go +++ b/vite/console/inputs_command.go @@ -190,7 +190,12 @@ func (r *InputsCommand) moduleInputs() ([]ModuleInputs, error) { input := ModuleInputs{Name: name, Path: dir, Entries: []string{}, Aliases: map[string]string{}} if p, ok := module.(contractsmodular.ProvidesViteEntries); ok { for _, entry := range p.ViteEntries() { - input.Entries = append(input.Entries, stdpath.Join(dir, filepath.ToSlash(entry))) + // Checked like aliases: Vite would build a file outside the module otherwise. + cleaned, inside := path.CleanRelative(entry) + if !inside || cleaned == "." { + return nil, errors.ModularViteEntryInvalid.Args(name, entry) + } + input.Entries = append(input.Entries, stdpath.Join(dir, cleaned)) } } if p, ok := module.(contractsmodular.ProvidesViteAlias); ok { diff --git a/vite/console/inputs_command_modules_test.go b/vite/console/inputs_command_modules_test.go index 32aff8fac..a8564d67b 100644 --- a/vite/console/inputs_command_modules_test.go +++ b/vite/console/inputs_command_modules_test.go @@ -152,6 +152,19 @@ func TestInputsCommand_ModuleAliasErrors(t *testing.T) { } } +func TestInputsCommand_ModuleEntryErrors(t *testing.T) { + setupAppViews(t, nil) + + for _, entry := range []string{"../shop/app.js", "resources/../../shop/app.js", "/etc/app.js", "", "."} { + t.Run(entry, func(t *testing.T) { + blog := &viteModule{testModule: testModule{"blog"}, entries: []string{"resources/js/app.js", entry}} + cmd := moduleCommand(t, moduleRegistry(t, blog), nil, false) + + assert.EqualError(t, cmd.Handle(mocksconsole.NewContext(t)), errors.ModularViteEntryInvalid.Args("blog", entry).Error()) + }) + } +} + func TestInputsCommand_ModulePublished(t *testing.T) { setupAppViews(t, nil) From f5fd0d05bb91744a955e086c42945821ad58b5f2 Mon Sep 17 00:00:00 2001 From: Saad Date: Fri, 13 Nov 2026 12:23:32 +0600 Subject: [PATCH 27/29] fix: apply correct directory separator on windows --- modular/console/init_command.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modular/console/init_command.go b/modular/console/init_command.go index c752da218..3a3d643ae 100644 --- a/modular/console/init_command.go +++ b/modular/console/init_command.go @@ -54,7 +54,7 @@ func (r *InitCommand) Handle(ctx console.Context) error { facadesImport, facadesPackage := facadesImport() configStub := Stubs{}.Config(packages.Paths().Config().Package(), facadesImport, facadesPackage) - if err := putIfMissing(ctx, packages.Paths().Config().String("modular.go"), path.Config("modular.go"), configStub); err != nil { + if err := putIfMissing(ctx, filepath.ToSlash(packages.Paths().Config().String("modular.go")), path.Config("modular.go"), configStub); err != nil { return err } From 36cf2201712c31837054da6c99a76dcb499c7d4f Mon Sep 17 00:00:00 2001 From: Saad Date: Fri, 13 Nov 2026 12:27:17 +0600 Subject: [PATCH 28/29] test(modular): cover modular:init with an unparsable app.go --- modular/console/init_command_test.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/modular/console/init_command_test.go b/modular/console/init_command_test.go index 9d88b6315..9851e3c5c 100644 --- a/modular/console/init_command_test.go +++ b/modular/console/init_command_test.go @@ -80,6 +80,20 @@ func (s *InitCommandTestSuite) TestRequiresBootstrapSetup() { s.False(file.Exists("modules")) } +func (s *InitCommandTestSuite) TestReturnsUnparsableApp() { + s.Require().NoError(file.PutContent("bootstrap/app.go", "package bootstrap\n\nvar app = foundation.Setup().Create(\n")) + + ctx := mocksconsole.NewContext(s.T()) + ctx.EXPECT().TwoColumnDetail("modules/.gitkeep", statusCreated).Once() + ctx.EXPECT().TwoColumnDetail("config/modular.go", statusCreated).Once() + + err := NewInitCommand(nil).Handle(ctx) + + s.Error(err) + s.False(isManualWiring(err)) + s.False(file.Exists("bootstrap/modules.go")) +} + func (s *InitCommandTestSuite) TestCreatesThenReportsExisting() { s.Require().NoError(file.PutContent("bootstrap/app.go", chainedApp)) s.Require().NoError(file.PutContent("main.go", mainFile)) From 17cc61566d4d9c99f989e9e08bd20c9fce4a3571 Mon Sep 17 00:00:00 2001 From: Saad Date: Sat, 14 Nov 2026 10:51:52 +0600 Subject: [PATCH 29/29] refactor: remove CreateIn and add dir param in the Create instead in Migrator --- contracts/database/migration/migrator.go | 6 +- .../console/migration/migrate_make_command.go | 8 +- .../migration/migrate_make_command_test.go | 2 +- database/migration/migrator.go | 12 +-- database/migration/migrator_test.go | 8 +- mocks/database/migration/Migrator.go | 101 +++++------------- 6 files changed, 44 insertions(+), 93 deletions(-) diff --git a/contracts/database/migration/migrator.go b/contracts/database/migration/migrator.go index 873434229..00512ec36 100644 --- a/contracts/database/migration/migrator.go +++ b/contracts/database/migration/migrator.go @@ -12,10 +12,8 @@ type Status struct { } type Migrator interface { - // Create a new migration file. - Create(name string, modelName string) (string, error) - // CreateIn creates a new migration file in dir, relative to the project root. - CreateIn(dir string, name string, modelName string) (string, error) + // Create a new migration file, in the joined dir (relative to the project root) if given, else the migrations path. + Create(name string, modelName string, dir ...string) (string, error) // Fresh drops all tables and re-runs all migrations from scratch. Fresh() error // Reset the migrations. diff --git a/database/console/migration/migrate_make_command.go b/database/console/migration/migrate_make_command.go index b7f595c12..6e22218c3 100644 --- a/database/console/migration/migrate_make_command.go +++ b/database/console/migration/migrate_make_command.go @@ -72,12 +72,12 @@ func (r *MigrateMakeCommand) makeOne(ctx console.Context, name, modelName string moduleDir := makeMigration.GetModuleDir() - var fileName string + var dir []string if moduleDir != "" { - fileName, err = r.migrator.CreateIn(supportconsole.ModuleRoot(moduleDir, support.Config.Paths.Migrations), makeMigration.GetName(), modelName) - } else { - fileName, err = r.migrator.Create(makeMigration.GetName(), modelName) + dir = append(dir, supportconsole.ModuleRoot(moduleDir, support.Config.Paths.Migrations)) } + + fileName, err := r.migrator.Create(makeMigration.GetName(), modelName, dir...) if err != nil { return errors.MigrationCreateFailed.Args(err) } diff --git a/database/console/migration/migrate_make_command_test.go b/database/console/migration/migrate_make_command_test.go index e23694515..7e7685a48 100644 --- a/database/console/migration/migrate_make_command_test.go +++ b/database/console/migration/migrate_make_command_test.go @@ -280,7 +280,7 @@ func (m *Module) Name() string { mockContext.EXPECT().Option("model").Return("").Once() mockContext.EXPECT().Option("module").Return("blog").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() - mockMigrator.EXPECT().CreateIn("modules/blog/database/migrations", "create_posts_table", "").Return("20240915060148_create_posts_table", nil).Once() + mockMigrator.EXPECT().Create("create_posts_table", "", "modules/blog/database/migrations").Return("20240915060148_create_posts_table", nil).Once() mockContext.EXPECT().Success("Created Migration: create_posts_table").Once() mockContext.EXPECT().Success("Migration registered successfully").Once() diff --git a/database/migration/migrator.go b/database/migration/migrator.go index 67f471108..73c21b223 100644 --- a/database/migration/migrator.go +++ b/database/migration/migrator.go @@ -35,12 +35,8 @@ func NewMigrator(artisan console.Artisan, schema contractsschema.Schema, table s } } -func (r *Migrator) Create(name string, modelName string) (string, error) { - return r.CreateIn("", name, modelName) -} - -// CreateIn writes the migration to dir instead of the migrations path, "" for the default. -func (r *Migrator) CreateIn(dir string, name string, modelName string) (string, error) { +// Create writes the migration to the joined dir instead of the migrations path when given. +func (r *Migrator) Create(name string, modelName string, dir ...string) (string, error) { table, create := TableGuesser{}.Guess(name) var schemaFields []string @@ -68,8 +64,8 @@ func (r *Migrator) CreateIn(dir string, name string, modelName string) (string, } filePath, pkg := r.creator.GetPath(fileName), packages.Paths().Migrations().Package() - if dir != "" { - filePath, pkg = path.Base(filepath.FromSlash(dir), fileName+".go"), filepath.Base(filepath.FromSlash(dir)) + if d := filepath.Join(dir...); d != "" { + filePath, pkg = path.Base(d, fileName+".go"), filepath.Base(d) } templateData := StubData{ diff --git a/database/migration/migrator_test.go b/database/migration/migrator_test.go index ef96e392e..91a55367a 100644 --- a/database/migration/migrator_test.go +++ b/database/migration/migrator_test.go @@ -75,7 +75,7 @@ func (s *MigratorSuite) TestCreate() { }() name := "create_posts_table" - fileName, err := s.migrator.CreateIn("modules/blog/database/migrations", name, "") + fileName, err := s.migrator.Create(name, "", "modules/blog/database/migrations") s.NoError(err) s.Equal("20240817214501_"+name, fileName) s.True(file.Contain(filepath.Join(pwd, "modules", "blog", "database", "migrations", fileName+".go"), "package migrations")) @@ -91,7 +91,7 @@ func (s *MigratorSuite) TestCreate() { s.NoError(file.PutContent(filepath.Join("bootstrap", "app.go"), "package bootstrap\n\nfunc Boot() { foundation.Setup().Create() }\n")) // Without the app's Schema facade the generated file falls back to the framework's facades. - fileName, err := s.migrator.CreateIn("modules/blog/database/migrations", "create_posts_table", "") + fileName, err := s.migrator.Create("create_posts_table", "", "modules/blog/database/migrations") s.NoError(err) migration := filepath.Join(pwd, "modules", "blog", "database", "migrations", fileName+".go") s.True(file.Contain(migration, `"github.com/goravel/framework/facades"`)) @@ -100,7 +100,7 @@ func (s *MigratorSuite) TestCreate() { // The package name falls back with the import, also when the app's is named differently. facadesPath := support.Config.Paths.Facades support.Config.Paths.Facades = "app/support/f" - fileName, err = s.migrator.CreateIn("modules/blog/database/migrations", "create_posts_table", "") + fileName, err = s.migrator.Create("create_posts_table", "", "modules/blog/database/migrations") support.Config.Paths.Facades = facadesPath s.NoError(err) migration = filepath.Join(pwd, "modules", "blog", "database", "migrations", fileName+".go") @@ -109,7 +109,7 @@ func (s *MigratorSuite) TestCreate() { s.NoError(file.Remove(migration)) s.NoError(file.PutContent(filepath.Join("app", "facades", "schema.go"), "package facades\n")) - fileName, err = s.migrator.CreateIn("modules/blog/database/migrations", "create_posts_table", "") + fileName, err = s.migrator.Create("create_posts_table", "", "modules/blog", "database/migrations") s.NoError(err) s.True(file.Contain(filepath.Join(pwd, "modules", "blog", "database", "migrations", fileName+".go"), `/app/facades"`)) }) diff --git a/mocks/database/migration/Migrator.go b/mocks/database/migration/Migrator.go index adb320a41..c1d9f90d3 100644 --- a/mocks/database/migration/Migrator.go +++ b/mocks/database/migration/Migrator.go @@ -20,9 +20,16 @@ func (_m *Migrator) EXPECT() *Migrator_Expecter { return &Migrator_Expecter{mock: &_m.Mock} } -// Create provides a mock function with given fields: name, modelName -func (_m *Migrator) Create(name string, modelName string) (string, error) { - ret := _m.Called(name, modelName) +// Create provides a mock function with given fields: name, modelName, dir +func (_m *Migrator) Create(name string, modelName string, dir ...string) (string, error) { + _va := make([]interface{}, len(dir)) + for _i := range dir { + _va[_i] = dir[_i] + } + var _ca []interface{} + _ca = append(_ca, name, modelName) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) if len(ret) == 0 { panic("no return value specified for Create") @@ -30,17 +37,17 @@ func (_m *Migrator) Create(name string, modelName string) (string, error) { var r0 string var r1 error - if rf, ok := ret.Get(0).(func(string, string) (string, error)); ok { - return rf(name, modelName) + if rf, ok := ret.Get(0).(func(string, string, ...string) (string, error)); ok { + return rf(name, modelName, dir...) } - if rf, ok := ret.Get(0).(func(string, string) string); ok { - r0 = rf(name, modelName) + if rf, ok := ret.Get(0).(func(string, string, ...string) string); ok { + r0 = rf(name, modelName, dir...) } else { r0 = ret.Get(0).(string) } - if rf, ok := ret.Get(1).(func(string, string) error); ok { - r1 = rf(name, modelName) + if rf, ok := ret.Get(1).(func(string, string, ...string) error); ok { + r1 = rf(name, modelName, dir...) } else { r1 = ret.Error(1) } @@ -56,13 +63,21 @@ type Migrator_Create_Call struct { // Create is a helper method to define mock.On call // - name string // - modelName string -func (_e *Migrator_Expecter) Create(name interface{}, modelName interface{}) *Migrator_Create_Call { - return &Migrator_Create_Call{Call: _e.mock.On("Create", name, modelName)} +// - dir ...string +func (_e *Migrator_Expecter) Create(name interface{}, modelName interface{}, dir ...interface{}) *Migrator_Create_Call { + return &Migrator_Create_Call{Call: _e.mock.On("Create", + append([]interface{}{name, modelName}, dir...)...)} } -func (_c *Migrator_Create_Call) Run(run func(name string, modelName string)) *Migrator_Create_Call { +func (_c *Migrator_Create_Call) Run(run func(name string, modelName string, dir ...string)) *Migrator_Create_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(string), args[1].(string)) + variadicArgs := make([]string, len(args)-2) + for i, a := range args[2:] { + if a != nil { + variadicArgs[i] = a.(string) + } + } + run(args[0].(string), args[1].(string), variadicArgs...) }) return _c } @@ -72,65 +87,7 @@ func (_c *Migrator_Create_Call) Return(_a0 string, _a1 error) *Migrator_Create_C return _c } -func (_c *Migrator_Create_Call) RunAndReturn(run func(string, string) (string, error)) *Migrator_Create_Call { - _c.Call.Return(run) - return _c -} - -// CreateIn provides a mock function with given fields: dir, name, modelName -func (_m *Migrator) CreateIn(dir string, name string, modelName string) (string, error) { - ret := _m.Called(dir, name, modelName) - - if len(ret) == 0 { - panic("no return value specified for CreateIn") - } - - var r0 string - var r1 error - if rf, ok := ret.Get(0).(func(string, string, string) (string, error)); ok { - return rf(dir, name, modelName) - } - if rf, ok := ret.Get(0).(func(string, string, string) string); ok { - r0 = rf(dir, name, modelName) - } else { - r0 = ret.Get(0).(string) - } - - if rf, ok := ret.Get(1).(func(string, string, string) error); ok { - r1 = rf(dir, name, modelName) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// Migrator_CreateIn_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateIn' -type Migrator_CreateIn_Call struct { - *mock.Call -} - -// CreateIn is a helper method to define mock.On call -// - dir string -// - name string -// - modelName string -func (_e *Migrator_Expecter) CreateIn(dir interface{}, name interface{}, modelName interface{}) *Migrator_CreateIn_Call { - return &Migrator_CreateIn_Call{Call: _e.mock.On("CreateIn", dir, name, modelName)} -} - -func (_c *Migrator_CreateIn_Call) Run(run func(dir string, name string, modelName string)) *Migrator_CreateIn_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(string), args[1].(string), args[2].(string)) - }) - return _c -} - -func (_c *Migrator_CreateIn_Call) Return(_a0 string, _a1 error) *Migrator_CreateIn_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *Migrator_CreateIn_Call) RunAndReturn(run func(string, string, string) (string, error)) *Migrator_CreateIn_Call { +func (_c *Migrator_Create_Call) RunAndReturn(run func(string, string, ...string) (string, error)) *Migrator_Create_Call { _c.Call.Return(run) return _c }