diff --git a/Makefile b/Makefile index 6d991923..b83464a9 100644 --- a/Makefile +++ b/Makefile @@ -403,6 +403,7 @@ mock: ## Generate all the mocks (for tests) @echo "${COLOR_CYAN} 🧱 Generating all the mocks${COLOR_RESET}" @go install github.com/golang/mock/mockgen@v1.6.0 @mockgen -source=x/logic/types/expected_keepers.go -package testutil -destination x/logic/testutil/expected_keepers_mocks.go + @mockgen -source=x/logic/fs/fs.go -package testutil -destination x/logic/testutil/fs_mocks.go ## Release: .PHONY: release-assets diff --git a/app/app.go b/app/app.go index a7741906..2f40332c 100644 --- a/app/app.go +++ b/app/app.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "io" - "io/fs" "net/http" "os" "path/filepath" @@ -1017,7 +1016,7 @@ func (app *App) SimulationManager() *module.SimulationManager { // provideFS is used to provide the virtual file system used for the logic module // to load external file. -func (app *App) provideFS(ctx context.Context) fs.FS { +func (app *App) provideFS(ctx context.Context) logicfs.FS { wasmHandler := logicfs.NewWasmHandler(app.WasmKeeper) return logicfs.NewVirtualFS(ctx, []logicfs.URIHandler{wasmHandler}) } diff --git a/x/logic/fs/fs.go b/x/logic/fs/fs.go index 9529e6ab..b7c50004 100644 --- a/x/logic/fs/fs.go +++ b/x/logic/fs/fs.go @@ -5,6 +5,10 @@ import ( "io/fs" ) +type FS interface { + fs.FS +} + // VirtualFS is the custom virtual file system used into the blockchain. // It will hold a list of handler that can resolve file URI and return the corresponding binary file. type VirtualFS struct { @@ -12,7 +16,7 @@ type VirtualFS struct { router Router } -var _ fs.FS = (*VirtualFS)(nil) +var _ FS = (*VirtualFS)(nil) // NewVirtualFS return a new VirtualFS object that will handle all virtual file on the interpreter. // File can be provided from different sources like CosmWasm cw-storage smart contract. diff --git a/x/logic/interpreter/registry.go b/x/logic/interpreter/registry.go index 26db4afe..2a6eb2e9 100644 --- a/x/logic/interpreter/registry.go +++ b/x/logic/interpreter/registry.go @@ -118,6 +118,7 @@ var Registry = map[string]RegistryEntry{ "sha_hash/2": {predicate.SHAHash, 1}, "hex_bytes/2": {predicate.HexBytes, 1}, "bech32_address/2": {predicate.Bech32Address, 1}, + "source_file/1": {predicate.SourceFile, 1}, } // RegistryNames is the list of the predicate names in the Registry. diff --git a/x/logic/keeper/keeper.go b/x/logic/keeper/keeper.go index 2d638a46..3999ff4e 100644 --- a/x/logic/keeper/keeper.go +++ b/x/logic/keeper/keeper.go @@ -3,12 +3,12 @@ package keeper import ( goctx "context" "fmt" - "io/fs" "github.com/cosmos/cosmos-sdk/codec" storetypes "github.com/cosmos/cosmos-sdk/store/types" sdk "github.com/cosmos/cosmos-sdk/types" paramtypes "github.com/cosmos/cosmos-sdk/x/params/types" + "github.com/okp4/okp4d/x/logic/fs" "github.com/okp4/okp4d/x/logic/types" "github.com/tendermint/tendermint/libs/log" ) diff --git a/x/logic/predicate/file.go b/x/logic/predicate/file.go new file mode 100644 index 00000000..11643e1e --- /dev/null +++ b/x/logic/predicate/file.go @@ -0,0 +1,93 @@ +package predicate + +import ( + "context" + "fmt" + "reflect" + "sort" + + "github.com/ichiban/prolog/engine" +) + +// SourceFile is a predicate that unify the given term with the currently loaded source file. The signature is as follows: +// +// source_file(?File). +// +// Where File represents a loaded source file. +// +// Example: +// +// # Query all the loaded source files, in alphanumeric order. +// - source_file(File). +// +// # Query the given source file is loaded. +// - source_file('foo.pl'). +func SourceFile(vm *engine.VM, file engine.Term, cont engine.Cont, env *engine.Env) *engine.Promise { + loaded := getLoadedSources(vm) + + inputFile, err := getFile(env, file) + if err != nil { + return engine.Error(fmt.Errorf("source_file/1: %w", err)) + } + + if inputFile != nil { + if _, ok := loaded[*inputFile]; ok { + return engine.Unify(vm, file, engine.NewAtom(*inputFile), cont, env) + } + return engine.Delay() + } + + promises := make([]func(ctx context.Context) *engine.Promise, 0, len(loaded)) + sortedSource := sortLoadedSources(loaded) + for i := range sortedSource { + term := engine.NewAtom(sortedSource[i]) + promises = append( + promises, + func(ctx context.Context) *engine.Promise { + return engine.Unify( + vm, + file, + term, + cont, + env, + ) + }) + } + + return engine.Delay(promises...) +} + +func getLoadedSources(vm *engine.VM) map[string]struct{} { + loadedField := reflect.ValueOf(vm).Elem().FieldByName("loaded").MapKeys() + loaded := make(map[string]struct{}, len(loadedField)) + for _, value := range loadedField { + loaded[value.String()] = struct{}{} + } + + return loaded +} + +func sortLoadedSources(sources map[string]struct{}) []string { + result := make([]string, 0, len(sources)) + for filename := range sources { + result = append(result, filename) + } + sort.SliceStable(result, func(i, j int) bool { + return result[i] < result[j] + }) + + return result +} + +//nolint:nilnil +func getFile(env *engine.Env, term engine.Term) (*string, error) { + switch file := env.Resolve(term).(type) { + case engine.Variable: + case engine.Atom: + strFile := file.String() + return &strFile, nil + default: + return nil, fmt.Errorf("cannot unify file with %T", term) + } + return nil, nil +} diff --git a/x/logic/predicate/file_test.go b/x/logic/predicate/file_test.go new file mode 100644 index 00000000..ac07e0ab --- /dev/null +++ b/x/logic/predicate/file_test.go @@ -0,0 +1,138 @@ +//nolint:gocognit +package predicate + +import ( + "fmt" + "net/url" + "testing" + "time" + + "github.com/cosmos/cosmos-sdk/store" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/golang/mock/gomock" + "github.com/ichiban/prolog/engine" + "github.com/okp4/okp4d/x/logic/fs" + "github.com/okp4/okp4d/x/logic/testutil" + "github.com/okp4/okp4d/x/logic/types" + . "github.com/smartystreets/goconvey/convey" + "github.com/tendermint/tendermint/libs/log" + tmproto "github.com/tendermint/tendermint/proto/tendermint/types" + tmdb "github.com/tendermint/tm-db" +) + +func TestSourceFile(t *testing.T) { + Convey("Given test cases", t, func() { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + cases := []struct { + query string + wantResult []types.TermResults + wantError error + wantSuccess bool + }{ + { + query: "source_file(file).", + wantSuccess: false, + }, + { + query: "consult(file1), consult(file2), source_file(file1).", + wantResult: []types.TermResults{{}}, + wantSuccess: true, + }, + { + query: "consult(file1), consult(file2), consult(file3), source_file(file2).", + wantResult: []types.TermResults{{}}, + wantSuccess: true, + }, + { + query: "consult(file1), consult(file2), source_file(file3).", + wantSuccess: false, + }, + { + query: "source_file(X).", + wantSuccess: false, + }, + { + query: "consult(file1), consult(file2), source_file(X).", + wantResult: []types.TermResults{{"X": "file1"}, {"X": "file2"}}, + wantSuccess: true, + }, + { + query: "consult(file2), consult(file3), consult(file1), source_file(X).", + wantResult: []types.TermResults{{"X": "file1"}, {"X": "file2"}, {"X": "file3"}}, + wantSuccess: true, + }, + { + query: "source_file(foo(bar)).", + wantResult: []types.TermResults{}, + wantError: fmt.Errorf("source_file/1: cannot unify file with *engine.compound"), + }, + } + + for nc, tc := range cases { + Convey(fmt.Sprintf("Given the query #%d: %s", nc, tc.query), func() { + Convey("and a mocked file system", func() { + uri, _ := url.Parse("file://dump.pl") + mockedFS := testutil.NewMockFS(ctrl) + mockedFS.EXPECT().Open(gomock.Any()).AnyTimes().Return(fs.NewVirtualFile( + []byte("dumb(dumber)."), + uri, + time.Now(), + ), nil) + + Convey("and a context", func() { + db := tmdb.NewMemDB() + stateStore := store.NewCommitMultiStore(db) + ctx := sdk.NewContext(stateStore, tmproto.Header{}, false, log.NewNopLogger()) + + Convey("and a vm", func() { + interpreter := testutil.NewInterpreterMust(ctx) + interpreter.FS = mockedFS + interpreter.Register1(engine.NewAtom("source_file"), SourceFile) + + Convey("When the predicate is called", func() { + sols, err := interpreter.QueryContext(ctx, tc.query) + + Convey("Then the error should be nil", func() { + So(err, ShouldBeNil) + So(sols, ShouldNotBeNil) + + Convey("and the bindings should be as expected", func() { + var got []types.TermResults + for sols.Next() { + m := types.TermResults{} + err := sols.Scan(m) + So(err, ShouldBeNil) + + got = append(got, m) + } + + if tc.wantError != nil { + So(sols.Err(), ShouldNotBeNil) + So(sols.Err().Error(), ShouldEqual, tc.wantError.Error()) + } else { + So(sols.Err(), ShouldBeNil) + + if tc.wantSuccess { + So(len(got), ShouldBeGreaterThan, 0) + So(len(got), ShouldEqual, len(tc.wantResult)) + for iGot, resultGot := range got { + for varGot, termGot := range resultGot { + So(testutil.ReindexUnknownVariables(termGot), ShouldEqual, tc.wantResult[iGot][varGot]) + } + } + } else { + So(len(got), ShouldEqual, 0) + } + } + }) + }) + }) + }) + }) + }) + }) + } + }) +} diff --git a/x/logic/testutil/fs_mocks.go b/x/logic/testutil/fs_mocks.go new file mode 100644 index 00000000..89c9f8ef --- /dev/null +++ b/x/logic/testutil/fs_mocks.go @@ -0,0 +1,50 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: x/logic/fs/fs.go + +// Package testutil is a generated GoMock package. +package testutil + +import ( + fs "io/fs" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockFS is a mock of FS interface. +type MockFS struct { + ctrl *gomock.Controller + recorder *MockFSMockRecorder +} + +// MockFSMockRecorder is the mock recorder for MockFS. +type MockFSMockRecorder struct { + mock *MockFS +} + +// NewMockFS creates a new mock instance. +func NewMockFS(ctrl *gomock.Controller) *MockFS { + mock := &MockFS{ctrl: ctrl} + mock.recorder = &MockFSMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFS) EXPECT() *MockFSMockRecorder { + return m.recorder +} + +// Open mocks base method. +func (m *MockFS) Open(name string) (fs.File, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Open", name) + ret0, _ := ret[0].(fs.File) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Open indicates an expected call of Open. +func (mr *MockFSMockRecorder) Open(name interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Open", reflect.TypeOf((*MockFS)(nil).Open), name) +} diff --git a/x/logic/testutil/logic.go b/x/logic/testutil/logic.go index 6613fbff..748af249 100644 --- a/x/logic/testutil/logic.go +++ b/x/logic/testutil/logic.go @@ -16,6 +16,7 @@ func NewInterpreterMust(ctx context.Context) (interpreter *prolog.Interpreter) { interpreter.Register3(engine.NewAtom("op"), engine.Op) interpreter.Register3(engine.NewAtom("compare"), engine.Compare) interpreter.Register2(engine.NewAtom("="), engine.Unify) + interpreter.Register1(engine.NewAtom("consult"), engine.Consult) err := interpreter.Compile(ctx, ` :-(op(1200, xfx, ':-')).