diff --git a/README.md b/README.md index 894862f..d4fd678 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ It's pretty fast. Not as fast as native Trealla, but pretty dang fast (2-5x slow ### Caveats - Alpha status, API will change. -- Queries are findall'd and won't return answers until they terminate. +- ~~Queries are findall'd and won't return answers until they terminate.~~ - Doesn't work on Windows ([wasmer-go issue](https://github.com/wasmerio/wasmer-go/issues/69)). - Works great on WSL. - ~~Currently interpreters are ephemeral, so you have to reconsult everything each query (working on this)~~. @@ -26,9 +26,17 @@ func main() { // load the interpreter and (optionally) grant access to the current directory pl := trealla.New(trealla.WithPreopen(".")) // run a query; cancel context to abort it - answer, err := pl.Query(ctx, "member(X, [1, foo(bar), c]).") - // get the second substitution (answer) for X - x := answer.Solutions[1]["X"] // trealla.Compound{Functor: "foo", Args: ["bar"]} + query := pl.Query(ctx, "member(X, [1, foo(bar), c]).") + // iterate through answers + for query.Next(ctx) { + answer := query.Current() + x := answer.Solution["X"] + fmt.Println(x) // 1, trealla.Compound{Functor: "foo", Args: ["bar"]}, "c" + } + // make sure to check the query for errors + if err := query.Err(); err != nil { + panic(err) + } } ``` diff --git a/trealla/answer.go b/trealla/answer.go new file mode 100644 index 0000000..d53f1b7 --- /dev/null +++ b/trealla/answer.go @@ -0,0 +1,82 @@ +package trealla + +import ( + "encoding/json" + "fmt" + "strings" +) + +// Answer is a query result. +type Answer struct { + // Query is the original query goal. + Query string + // Solution (substitutions) for a successful query. + // Indexed by variable name. + Solution Solution `json:"answer"` + // Output is captured stdout text from this query. + Output string +} + +type response struct { + Answer + Result queryStatus + Error json.RawMessage // ball +} + +func newAnswer(program, raw string) (Answer, error) { + if len(strings.TrimSpace(raw)) == 0 { + return Answer{}, ErrFailure + } + + start := strings.IndexRune(raw, stx) + end := strings.IndexRune(raw, etx) + nl := strings.IndexRune(raw[end+1:], '\n') + end + 1 + butt := len(raw) + if nl >= 0 { + butt = nl + } + + output := raw[start+1 : end] + js := raw[end+1 : butt] + + resp := response{ + Answer: Answer{ + Query: program, + Output: output, + }, + } + + dec := json.NewDecoder(strings.NewReader(js)) + dec.UseNumber() + if err := dec.Decode(&resp); err != nil { + return resp.Answer, fmt.Errorf("trealla: decoding error: %w", err) + } + + switch resp.Result { + case statusSuccess: + return resp.Answer, nil + case statusFailure: + return resp.Answer, ErrFailure + case statusError: + ball, err := unmarshalTerm(resp.Error) + if err != nil { + return resp.Answer, err + } + return resp.Answer, ErrThrow{Ball: ball} + default: + return resp.Answer, fmt.Errorf("trealla: unexpected query status: %v", resp.Result) + } +} + +// queryStatus is the status of a query answer. +type queryStatus string + +// Result values. +const ( + // statusSuccess is for queries that succeed. + statusSuccess queryStatus = "success" + // statusFailure is for queries that fail (find no answers). + statusFailure queryStatus = "failure" + // statusError is for queries that throw an error. + statusError queryStatus = "error" +) diff --git a/trealla/bench_test.go b/trealla/bench_test.go index 6ee347f..4782dea 100644 --- a/trealla/bench_test.go +++ b/trealla/bench_test.go @@ -13,8 +13,11 @@ func BenchmarkQuery(b *testing.B) { ctx := context.Background() b.ResetTimer() for i := 0; i < b.N; i++ { - _, err = pl.Query(ctx, "X=1, write(X)") - if err != nil { + q := pl.Query(ctx, "X=1, write(X)") + if !q.Next(ctx) { + b.Fatal("no answer") + } + if q.Err() != nil { b.Fatal(err) } } @@ -28,8 +31,11 @@ func BenchmarkTak(b *testing.B) { ctx := context.Background() b.ResetTimer() for i := 0; i < b.N; i++ { - _, err := pl.Query(ctx, "consult('testdata/tak'), run") - if err != nil { + q := pl.Query(ctx, "consult('testdata/tak'), run") + if !q.Next(ctx) { + b.Fatal("no answer") + } + if q.Err() != nil { b.Fatal(err) } } diff --git a/trealla/query.go b/trealla/query.go index 3eba7ad..925c53b 100644 --- a/trealla/query.go +++ b/trealla/query.go @@ -1,179 +1,243 @@ package trealla import ( + "bytes" "context" - "encoding/json" + "encoding/binary" + "errors" "fmt" + "runtime" "strings" + "sync" ) const stx = '\x02' // START OF TEXT const etx = '\x03' // END OF TEXT -// Query executes a query. -func (pl *prolog) Query(ctx context.Context, program string) (Answer, error) { - pl.mu.Lock() - defer pl.mu.Unlock() +// Query executes a query, returning an iterator for results. +func (pl *prolog) Query(ctx context.Context, goal string) Query { + q := pl.start(ctx, goal) + runtime.SetFinalizer(q, finalize) + return q +} - raw, err := pl.ask(ctx, program) - if err != nil { - return Answer{}, err - } +func finalize(q *query) { + q.Close() +} - if idx := strings.IndexRune(raw, stx); idx >= 0 { - raw = raw[idx+1:] - } else { - return Answer{}, fmt.Errorf("trealla: unexpected output (missing STX): %s", raw) +// QueryAnswer executes a query and returns a single result. +func (pl *prolog) QueryAnswer(ctx context.Context, goal string) (Answer, error) { + q := pl.Query(ctx, goal) + defer q.Close() + if q.Next(ctx) { + return q.Current(), nil } + return Answer{}, q.Err() +} - var output string - if idx := strings.IndexRune(raw, etx); idx >= 0 { - output = raw[:idx] - raw = raw[idx+1:] - } else { - return Answer{}, fmt.Errorf("trealla: unexpected output (missing ETX): %s", raw) - } +// Query is a Prolog query iterator. +type Query interface { + // Next computes the next solution. Returns true if it found one and false if there are no more results. + Next(context.Context) bool + // Current returns the current solution prepared by Next. + Current() Answer + // Close destroys this query. It is not necessary to call this if you exhaust results via Next. + Close() error + // Err returns this query's error. Always check this after iterating. + Err() error +} - if idx := strings.IndexRune(raw, stx); idx >= 0 { - raw = raw[:idx] +type query struct { + pl *prolog + goal string + subquery int32 + + queue []Answer + cur Answer + err error + done bool + + mu *sync.Mutex +} + +func (q *query) push(a Answer) { + q.queue = append(q.queue, a) +} + +func (q *query) pop() bool { + if len(q.queue) == 0 { + return false } + q.cur = q.queue[0] + q.queue = q.queue[1:] + return true +} - resp := response{ - Answer: Answer{ - Query: program, - Output: output, - }, +func (q *query) Next(ctx context.Context) bool { + q.mu.Lock() + defer q.mu.Unlock() + + if q.err != nil { + return false } - dec := json.NewDecoder(strings.NewReader(raw)) - dec.UseNumber() - if err := dec.Decode(&resp); err != nil { - return resp.Answer, fmt.Errorf("trealla: decoding error: %w", err) + if q.pop() { + return true } - switch resp.Result { - case statusSuccess: - return resp.Answer, nil - case statusFailure: - return resp.Answer, ErrFailure - case statusError: - ball, err := unmarshalTerm(resp.Error) - if err != nil { - return resp.Answer, err - } - return resp.Answer, ErrThrow{Ball: ball} - default: - return resp.Answer, fmt.Errorf("trealla: unexpected query status: %v", resp.Result) + if q.done { + return false } -} -// Answer is a query result. -type Answer struct { - // Query is the original query goal. - Query string - // Answers are the solutions (substitutions) for a successful query. - Answers []Solution - // Output is captured stdout text from this query. - Output string -} + if q.redo(ctx) { + return q.pop() + } -type response struct { - Answer - Result queryStatus - Error json.RawMessage // ball + return false } -// queryStatus is the status of a query answer. -type queryStatus string - -// Result values. -const ( - // statusSuccess is for queries that succeed. - statusSuccess queryStatus = "success" - // statusFailure is for queries that fail (find no answers). - statusFailure queryStatus = "failure" - // statusError is for queries that throw an error. - statusError queryStatus = "error" -) +func (pl *prolog) start(ctx context.Context, goal string) *query { + q := &query{ + pl: pl, + goal: goal, + mu: new(sync.Mutex), + } -func (pl *prolog) ask(ctx context.Context, query string) (string, error) { - query = escapeQuery(query) - qstr, err := newCString(pl, query) + goalstr, err := newCString(pl, escapeQuery(goal)) if err != nil { - return "", err + q.setError(err) + return q } - defer qstr.free(pl) + defer goalstr.free(pl) - pl_eval, err := pl.instance.Exports.GetFunction("pl_eval") + subqptrv, err := pl.realloc(0, 0, 0, 4) if err != nil { - return "", err + q.setError(err) + return q } + subqptr := subqptrv.(int32) + defer pl.free(subqptr, 4, 1) ch := make(chan error, 2) + var ret int32 go func() { defer func() { if ex := recover(); ex != nil { ch <- fmt.Errorf("trealla: panic: %v", ex) } }() - _, err := pl_eval(pl.ptr, qstr.ptr) + v, err := pl.pl_query(pl.ptr, goalstr.ptr, subqptr) + ret = v.(int32) ch <- err }() select { case <-ctx.Done(): - return "", fmt.Errorf("trealla: canceled: %w", ctx.Err()) + q.Close() + q.setError(fmt.Errorf("trealla: canceled: %w", ctx.Err())) + return q + case err := <-ch: + q.done = ret == 0 + if err != nil { + q.setError(err) + return q + } + + // grab subquery pointer + buf := bytes.NewBuffer(pl.memory.Data()[subqptr : subqptr+4]) + if err := binary.Read(buf, binary.LittleEndian, &q.subquery); err != nil { + q.setError(fmt.Errorf("trealla: couldn't read subquery pointer")) + return q + } + stdout := string(pl.wasi.ReadStdout()) - return stdout, err + ans, err := newAnswer(goal, stdout) + if err == nil { + q.push(ans) + } else { + q.setError(err) + } + return q } } -type cstring struct { - ptr int32 - size int -} +func (q *query) redo(ctx context.Context) bool { + pl := q.pl + ch := make(chan error, 2) + var ret int32 + go func() { + defer func() { + if ex := recover(); ex != nil { + ch <- fmt.Errorf("trealla: panic: %v", ex) + } + }() + v, err := pl.pl_redo(q.subquery) + ret = v.(int32) + ch <- err + }() -func newCString(pl *prolog, str string) (*cstring, error) { - cstr := &cstring{ - size: len(str) + 1, - } - size := len(str) + 1 - ptrv, err := pl.realloc(0, 0, 0, size) - if err != nil { - return nil, err + select { + case <-ctx.Done(): + q.setError(fmt.Errorf("trealla: canceled: %w", ctx.Err())) + q.Close() + return false + + case err := <-ch: + q.done = ret == 0 + if err != nil { + q.setError(err) + return false + } + + stdout := string(pl.wasi.ReadStdout()) + ans, err := newAnswer(q.goal, stdout) + switch { + case errors.Is(err, ErrFailure): + return false + case err != nil: + q.setError(err) + return false + } + q.push(ans) + return true } - cstr.ptr = ptrv.(int32) - err = cstr.set(pl, str) - return cstr, err } -func (cstr *cstring) set(pl *prolog, str string) error { - data := pl.memory.Data() +func (q *query) Current() Answer { + q.mu.Lock() + defer q.mu.Unlock() + return q.cur +} - ptr := int(cstr.ptr) - for i, b := range []byte(str) { - data[ptr+i] = b - } - data[ptr+len(str)] = 0 +func (q *query) Close() error { + q.mu.Lock() + defer q.mu.Unlock() + if !q.done && q.subquery != 0 { + q.pl.pl_done(q.subquery) + q.done = true + } return nil } -func (str *cstring) free(pl *prolog) error { - if str.ptr == 0 { - return nil +func (q *query) setError(err error) { + if err != nil && q.err == nil { + q.err = err } +} - _, err := pl.free(str.ptr, str.size, 0) - str.ptr = 0 - str.size = 0 - return err +func (q *query) Err() error { + q.mu.Lock() + defer q.mu.Unlock() + return q.err } func escapeQuery(query string) string { query = stringEscaper.Replace(query) - return fmt.Sprintf(`use_module(library(js_toplevel)), js_ask("%s")`, query) + return fmt.Sprintf(`js_ask("%s")`, query) } var stringEscaper = strings.NewReplacer(`\`, `\\`, `"`, `\"`) + +var _ Query = (*query)(nil) diff --git a/trealla/query_test.go b/trealla/query_test.go index caf64f5..1ae0583 100644 --- a/trealla/query_test.go +++ b/trealla/query_test.go @@ -3,6 +3,7 @@ package trealla_test import ( "context" "errors" + "fmt" "os" "reflect" "testing" @@ -29,70 +30,106 @@ func TestQuery(t *testing.T) { tests := []struct { name string - want trealla.Answer + want []trealla.Answer err error }{ { name: "true/0", - want: trealla.Answer{ - Query: `true.`, - Answers: []trealla.Solution{{}}, + want: []trealla.Answer{ + { + Query: `true.`, + Solution: trealla.Solution{}, + }, }, }, { name: "consulted", - want: trealla.Answer{ - Query: `hello(X)`, - Answers: []trealla.Solution{ - {"X": "world"}, - {"X": "Welt"}, - {"X": "世界"}, + want: []trealla.Answer{ + { + Query: `hello(X)`, + Solution: trealla.Solution{ + "X": "world", + }, + }, + { + Query: `hello(X)`, + Solution: trealla.Solution{ + "X": "Welt", + }, + }, + { + Query: `hello(X)`, + Solution: trealla.Solution{ + "X": "世界", + }, }, }, }, { name: "assertz/1", - want: trealla.Answer{ - Query: `assertz(こんにちは(世界)).`, - Answers: []trealla.Solution{{}}, + want: []trealla.Answer{ + { + Query: `assertz(こんにちは(世界)).`, + Solution: trealla.Solution{}, + }, }, }, { name: "assertz/1 (did it persist?)", - want: trealla.Answer{ - Query: `こんにちは(X).`, - Answers: []trealla.Solution{{"X": "世界"}}, + want: []trealla.Answer{ + { + Query: `こんにちは(X).`, + Solution: trealla.Solution{"X": "世界"}, + }, }, }, { name: "member/2", - want: trealla.Answer{ - Query: `member(X, [1,foo(bar),4.2,"baz",'boop', [q, '"'], '\\', '\n']).`, - Answers: []trealla.Solution{ - {"X": int64(1)}, - {"X": trealla.Compound{Functor: "foo", Args: []trealla.Term{"bar"}}}, - {"X": 4.2}, - {"X": "baz"}, - {"X": "boop"}, - {"X": []trealla.Term{"q", `"`}}, - {"X": `\`}, - {"X": "\n"}, + want: []trealla.Answer{ + { + Query: `member(X, [1,foo(bar),4.2,"baz",'boop', [q, '"'], '\\', '\n']).`, + Solution: trealla.Solution{"X": int64(1)}, }, + { + Query: `member(X, [1,foo(bar),4.2,"baz",'boop', [q, '"'], '\\', '\n']).`, + Solution: trealla.Solution{"X": trealla.Compound{Functor: "foo", Args: []trealla.Term{"bar"}}}}, + { + Query: `member(X, [1,foo(bar),4.2,"baz",'boop', [q, '"'], '\\', '\n']).`, + Solution: trealla.Solution{"X": 4.2}}, + { + Query: `member(X, [1,foo(bar),4.2,"baz",'boop', [q, '"'], '\\', '\n']).`, + Solution: trealla.Solution{"X": "baz"}}, + { + Query: `member(X, [1,foo(bar),4.2,"baz",'boop', [q, '"'], '\\', '\n']).`, + Solution: trealla.Solution{"X": "boop"}}, + { + Query: `member(X, [1,foo(bar),4.2,"baz",'boop', [q, '"'], '\\', '\n']).`, + Solution: trealla.Solution{"X": []trealla.Term{"q", `"`}}}, + { + Query: `member(X, [1,foo(bar),4.2,"baz",'boop', [q, '"'], '\\', '\n']).`, + Solution: trealla.Solution{"X": `\`}}, + { + Query: `member(X, [1,foo(bar),4.2,"baz",'boop', [q, '"'], '\\', '\n']).`, + Solution: trealla.Solution{"X": "\n"}}, }, }, { name: "false/0", - want: trealla.Answer{ - Query: `false.`, + want: []trealla.Answer{ + { + Query: `false.`, + }, }, err: trealla.ErrFailure, }, { name: "tak & WithLibraryPath", - want: trealla.Answer{ - Query: "use_module(library(tak)), run", - Answers: []trealla.Solution{{}}, - Output: "''([34,13,8],13).\n", + want: []trealla.Answer{ + { + Query: "use_module(library(tak)), run", + Solution: trealla.Solution{}, + Output: "''([34,13,8],13).\n", + }, }, }, } @@ -100,13 +137,19 @@ func TestQuery(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { ctx := context.Background() - ans, err := pl.Query(ctx, tc.want.Query) + q := pl.Query(ctx, tc.want[0].Query) + var ans []trealla.Answer + for q.Next(ctx) { + ans = append(ans, q.Current()) + } + err := q.Err() if tc.err == nil && err != nil { t.Fatal(err) } else if tc.err != nil && !errors.Is(err, tc.err) { t.Error("unexpected error:", err) } - if !reflect.DeepEqual(ans, tc.want) { + if tc.err == nil && !reflect.DeepEqual(ans, tc.want) { + // TODO t.Errorf("bad answer. want: %#v got: %#v", tc.want, ans) } }) @@ -121,7 +164,11 @@ func TestThrow(t *testing.T) { } ctx := context.Background() - _, err = pl.Query(ctx, `throw(ball).`) + q := pl.Query(ctx, `throw(ball).`) + if q.Next(ctx) { + t.Error("unexpected result", q.Current()) + } + err = q.Err() var ex trealla.ErrThrow if !errors.As(err, &ex) { @@ -140,7 +187,11 @@ func TestSyntaxError(t *testing.T) { } ctx := context.Background() - _, err = pl.Query(ctx, `hello(`) + q := pl.Query(ctx, `hello(`) + if q.Next(ctx) { + t.Error("unexpected result", q.Current()) + } + err = q.Err() var ex trealla.ErrThrow if !errors.As(err, &ex) { @@ -161,3 +212,31 @@ func TestSyntaxError(t *testing.T) { t.Error(`unexpected error value. want:`, want, `got:`, ex.Ball) } } + +func Example() { + ctx := context.Background() + + // create a new Prolog interpreter + pl, err := trealla.New() + if err != nil { + panic(err) + } + + // start a new query + query := pl.Query(ctx, "member(X, [1, foo(bar), c]).") + + // iterate through answers + for query.Next(ctx) { + answer := query.Current() + x := answer.Solution["X"] + fmt.Println(x) + } + + // make sure to check the query for errors + if err := query.Err(); err != nil { + panic(err) + } + // Output: 1 + // foo(bar) + // c +} diff --git a/trealla/string.go b/trealla/string.go new file mode 100644 index 0000000..a54282f --- /dev/null +++ b/trealla/string.go @@ -0,0 +1,41 @@ +package trealla + +import "fmt" + +type cstring struct { + ptr int32 + size int +} + +func newCString(pl *prolog, str string) (*cstring, error) { + cstr := &cstring{ + size: len(str) + 1, + } + + ptrv, err := pl.realloc(0, 0, 0, cstr.size) + if err != nil { + return nil, err + } + + cstr.ptr = ptrv.(int32) + if cstr.ptr == 0 { + return nil, fmt.Errorf("trealla: failed to allocate string: %s", str) + } + + data := pl.memory.Data() + ptr := int(cstr.ptr) + copy(data[ptr:], []byte(str)) + data[ptr+len(str)] = 0 + return cstr, nil +} + +func (str *cstring) free(pl *prolog) error { + if str.ptr == 0 { + return nil + } + + _, err := pl.free(str.ptr, str.size, 1) + str.ptr = 0 + str.size = 0 + return err +} diff --git a/trealla/tpl.wasm b/trealla/tpl.wasm index 1d6a107..5703a18 100755 Binary files a/trealla/tpl.wasm and b/trealla/tpl.wasm differ diff --git a/trealla/trealla.go b/trealla/trealla.go index ea43c27..c28c4d6 100644 --- a/trealla/trealla.go +++ b/trealla/trealla.go @@ -4,7 +4,6 @@ import ( "context" _ "embed" "fmt" - "sync" "github.com/wasmerio/wasmer-go/wasmer" ) @@ -17,7 +16,7 @@ var wasmEngine = wasmer.NewEngine() // Prolog is a Prolog interpreter. type Prolog interface { // Query executes a query. - Query(ctx context.Context, query string) (Answer, error) + Query(ctx context.Context, query string) Query // Consult loads a Prolog file with the given path. Consult(ctx context.Context, filename string) error } @@ -35,14 +34,14 @@ type prolog struct { ptr int32 realloc wasmFunc free wasmFunc - pl_eval wasmFunc + pl_query wasmFunc pl_consult wasmFunc + pl_redo wasmFunc + pl_done wasmFunc preopen string dirs map[string]string library string - - mu *sync.Mutex } // New creates a new Prolog interpreter. @@ -64,7 +63,6 @@ func newProlog(opts ...Option) (*prolog, error) { engine: wasmEngine, store: store, module: module, - mu: new(sync.Mutex), } for _, opt := range opts { opt(pl) @@ -141,11 +139,23 @@ func (pl *prolog) init() error { } pl.free = free - pl_eval, err := instance.Exports.GetFunction("pl_eval") + pl_query, err := instance.Exports.GetFunction("pl_query") + if err != nil { + return err + } + pl.pl_query = pl_query + + pl_redo, err := instance.Exports.GetFunction("pl_redo") if err != nil { return err } - pl.pl_eval = pl_eval + pl.pl_redo = pl_redo + + pl_done, err := instance.Exports.GetFunction("pl_done") + if err != nil { + return err + } + pl.pl_done = pl_done pl_consult, err := instance.Exports.GetFunction("pl_consult") if err != nil { @@ -157,9 +167,6 @@ func (pl *prolog) init() error { } func (pl *prolog) Consult(_ context.Context, filename string) error { - pl.mu.Lock() - defer pl.mu.Unlock() - fstr, err := newCString(pl, filename) if err != nil { return err diff --git a/wapm.lock b/wapm.lock index 4ed8d2a..244d49b 100644 --- a/wapm.lock +++ b/wapm.lock @@ -1,18 +1,18 @@ # Lockfile v4 # This file is automatically generated by Wapm. # It is not intended for manual editing. The schema of this file may change. -[modules."guregu/trealla"."0.3.1".tpl] +[modules."guregu/trealla"."0.5.1".tpl] name = "tpl" -package_version = "0.3.1" +package_version = "0.5.1" package_name = "guregu/trealla" -package_path = "guregu/trealla@0.3.1" -resolved = "https://registry-cdn.wapm.io/packages/guregu/trealla/trealla-0.3.1.tar.gz" +package_path = "guregu/trealla@0.5.1" +resolved = "https://registry-cdn.wapm.io/packages/guregu/trealla/trealla-0.5.1.tar.gz" resolved_source = "registry+tpl" abi = "wasi" source = "tpl.wasm" [commands.tpl] name = "tpl" package_name = "guregu/trealla" -package_version = "0.3.1" +package_version = "0.5.1" module = "tpl" is_top_level_dependency = true