From 8261a876d16ddad469ac6e3646e36f256d5e047d Mon Sep 17 00:00:00 2001 From: Brandon Dyck Date: Wed, 28 Aug 2024 23:01:38 -0600 Subject: [PATCH] Added canonical sexps sans parsing --- .gitignore | 1 + csexp/example_test.go | 32 ++++++++ csexp/gen/gen.go | 36 +++++++++ csexp/sexp.go | 87 ++++++++++++++++++++ csexp/sexp_test.go | 98 +++++++++++++++++++++++ go.mod | 5 +- go.sum | 4 + shortcircuit/shortcircuit.go | 79 +++++++++++++++++++ shortcircuit/shortcircuit_test.go | 127 ++++++++++++++++++++++++++++++ 9 files changed, 466 insertions(+), 3 deletions(-) create mode 100644 csexp/example_test.go create mode 100644 csexp/gen/gen.go create mode 100644 csexp/sexp.go create mode 100644 csexp/sexp_test.go create mode 100644 shortcircuit/shortcircuit.go create mode 100644 shortcircuit/shortcircuit_test.go diff --git a/.gitignore b/.gitignore index 722d5e7..864390b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .vscode +*.fail \ No newline at end of file diff --git a/csexp/example_test.go b/csexp/example_test.go new file mode 100644 index 0000000..7bbb83f --- /dev/null +++ b/csexp/example_test.go @@ -0,0 +1,32 @@ +package csexp_test + +import ( + "fmt" + "os" + + "git.codemonkeysoftware.net/b/peachy-go/csexp" +) + +func ExampleSexp_WriteTo() { + var sexp csexp.Sexp = csexp.List{ + csexp.Atom("Hello,"), + csexp.List{ + csexp.Atom("Mr."), + csexp.Atom("Rivest."), + }, + } + sexp.WriteTo(os.Stdout) + // Output: (6:Hello,(3:Mr.7:Rivest.)) +} + +func ExampleSexp_String() { + var sexp csexp.Sexp = csexp.List{ + csexp.Atom("Hello,"), + csexp.List{ + csexp.Atom("Mr."), + csexp.Atom("Rivest."), + }, + } + fmt.Print(sexp.String()) + // Output: (6:Hello,(3:Mr.7:Rivest.)) +} diff --git a/csexp/gen/gen.go b/csexp/gen/gen.go new file mode 100644 index 0000000..4867ac9 --- /dev/null +++ b/csexp/gen/gen.go @@ -0,0 +1,36 @@ +package gen + +import ( + "git.codemonkeysoftware.net/b/peachy-go/csexp" + "pgregory.net/rapid" +) + +type T rapid.TB + +func Atom() *rapid.Generator[csexp.Atom] { + return rapid.Custom(func(t *rapid.T) csexp.Atom { + return csexp.Atom(rapid.SliceOf(rapid.Byte()).Draw(t, "atom")) + }) +} + +func List() *rapid.Generator[csexp.List] { + return rapid.Custom(func(t *rapid.T) csexp.List { + var s []csexp.Sexp = rapid.SliceOfN(rapid.Deferred(Sexp), 0, 5).Draw(t, "s") + return csexp.List(s) + }) +} + +func AsSexp[T csexp.Sexp](value T) csexp.Sexp { + return csexp.Sexp(value) +} + +func Sexp() *rapid.Generator[csexp.Sexp] { + return rapid.Custom(func(t *rapid.T) csexp.Sexp { + choice := rapid.Uint8Range(0, 10).Draw(t, "choice") + // If we don't weight it enough towards atoms, it likes to overflow the stack. + if choice < 7 { + return rapid.Map(Atom(), AsSexp).Draw(t, "atom-sexp") + } + return rapid.Map(rapid.Deferred(List), AsSexp).Draw(t, "list-sexp") + }) +} diff --git a/csexp/sexp.go b/csexp/sexp.go new file mode 100644 index 0000000..e9dd54c --- /dev/null +++ b/csexp/sexp.go @@ -0,0 +1,87 @@ +package csexp + +import ( + "bytes" + "fmt" + "io" + "slices" + + "git.codemonkeysoftware.net/b/peachy-go/shortcircuit" +) + +type Sexp interface { + isSexp() + WriteTo(w io.Writer) (n int64, err error) + String() string + Equal(Sexp) bool + Clone() Sexp +} + +type Atom []byte + +func (a Atom) isSexp() {} + +func (a Atom) WriteTo(w io.Writer) (int64, error) { + if scw, ok := w.(*shortcircuit.Writer); ok && scw.Failed() { + return 0, nil + } + + n, err := fmt.Fprintf(w, "%d:%s", len(a), []byte(a)) + return int64(n), err +} + +func (a Atom) String() string { + var buf bytes.Buffer + a.WriteTo(&buf) + return buf.String() +} + +func (a Atom) Equal(s Sexp) bool { + a2, ok := s.(Atom) + return ok && bytes.Equal([]byte(a), []byte(a2)) +} + +func (a Atom) Clone() Sexp { + return Atom(bytes.Clone([]byte(a))) +} + +type List []Sexp + +func (l List) isSexp() {} + +func (l List) WriteTo(w io.Writer) (int64, error) { + scw := shortcircuit.EnsureWriter(w) + if scw.Failed() { + return 0, nil + } + + io.WriteString(scw, "(") + for _, child := range l { + child.WriteTo(scw) + } + io.WriteString(scw, ")") + return scw.Status() +} + +func (l List) String() string { + var buf bytes.Buffer + l.WriteTo(&buf) + return buf.String() +} + +func (l List) Equal(s Sexp) bool { + l2, ok := s.(List) + return ok && slices.EqualFunc(l, l2, Sexp.Equal) +} + +func (l List) Clone() Sexp { + l2 := make(List, len(l)) + for i, child := range l { + l2[i] = child.Clone() + } + return l2 +} + +func Parse(data []byte) (Sexp, error) { + return nil, nil +} diff --git a/csexp/sexp_test.go b/csexp/sexp_test.go new file mode 100644 index 0000000..a8126b7 --- /dev/null +++ b/csexp/sexp_test.go @@ -0,0 +1,98 @@ +package csexp_test + +import ( + "bytes" + "testing" + + "git.codemonkeysoftware.net/b/peachy-go/csexp" + "git.codemonkeysoftware.net/b/peachy-go/csexp/gen" + "github.com/shoenig/test/must" + "pgregory.net/rapid" +) + +func TestWriteToAndStringEquivalent(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + sexp := gen.Sexp().Draw(t, "sexp") + stringed := sexp.String() + var buf bytes.Buffer + n, err := sexp.WriteTo(&buf) + must.NoError(t, err) + written := buf.String() + must.EqOp(t, written, stringed) + must.EqOp(t, int64(len(stringed)), n) + }) +} + +func TestStringAndParseEqual(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + sexp := gen.Sexp().Draw(t, "sexp") + str := sexp.String() + parsed, err := csexp.Parse([]byte(str)) + must.NoError(t, err) + must.Equal(t, sexp, parsed) + }) +} + +func TestCloneEqual(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + sexp := gen.Sexp().Draw(t, "sexp") + cloned := sexp.Clone() + must.Equal(t, sexp, cloned) + }) +} + +func MustNotBeEqual(t rapid.TB, s1, s2 csexp.Sexp) { + e1 := s1.Equal(s2) + e2 := s2.Equal(s1) + if e1 == !e2 { + t.Logf("expected Sexp.Equal to be commutative, but got different results") + } + if e1 || e2 { + t.Fatalf("expected sexps not to be equal\ns1: %q\ns2: %q", string(s1.String()), string(s2.String())) + } +} + +func TestNotEqual(t *testing.T) { + t.Run("append to atom", rapid.MakeCheck(func(t *rapid.T) { + a1 := gen.Atom().Draw(t, "a1") + + a2 := a1.Clone().(csexp.Atom) + a2 = append(a2, 'x') + + MustNotBeEqual(t, a1, a2) + })) + t.Run("change atom byte", rapid.MakeCheck(func(t *rapid.T) { + nonEmpty := func(a csexp.Atom) bool { return len(a) >= 1 } + a1 := gen.Atom().Filter(nonEmpty).Draw(t, "a1") + + a2 := a1.Clone().(csexp.Atom) + a2[0] = a2[0] + 1 + + MustNotBeEqual(t, a1, a2) + })) + t.Run("append to list", rapid.MakeCheck(func(t *rapid.T) { + l1 := gen.List().Draw(t, "l1") + + extraElement := gen.Sexp().Draw(t, "extraElement") + l2 := l1.Clone().(csexp.List) + l2 = append(l2, extraElement) + + MustNotBeEqual(t, l1, l2) + })) + t.Run("change list element", rapid.MakeCheck(func(t *rapid.T) { + nonEmpty := func(l csexp.List) bool { return len(l) >= 1 } + l1 := gen.List().Filter(nonEmpty).Draw(t, "l1") + + isDifferent := func(s csexp.Sexp) bool { return !s.Equal(l1[0]) } + newElement := gen.Sexp().Filter(isDifferent).Draw(t, "newElement") + l2 := l1.Clone().(csexp.List) + l2[0] = newElement + + MustNotBeEqual(t, l1, l2) + })) + t.Run("list and atom", rapid.MakeCheck(func(t *rapid.T) { + atom := gen.Atom().Draw(t, "atom") + list := gen.List().Draw(t, "list") + MustNotBeEqual(t, atom, list) + })) +} diff --git a/go.mod b/go.mod index 0cb3927..78d0c2a 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,8 @@ go 1.22.1 require ( github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c - github.com/shoenig/test v1.8.2 + github.com/shoenig/test v1.9.0 + pgregory.net/rapid v1.1.0 zombiezen.com/go/sqlite v1.3.0 ) @@ -21,5 +22,3 @@ require ( modernc.org/memory v1.7.2 // indirect modernc.org/sqlite v1.29.1 // indirect ) - -replace github.com/shoenig/test v1.8.2 => ../shoenig-test diff --git a/go.sum b/go.sum index c04df06..939ba2a 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmd github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/shoenig/test v1.9.0 h1:PWtSP8Ty2N0F+Ndh4p0a8SOofFmTEIX/nYh/c3vRCbo= +github.com/shoenig/test v1.9.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= @@ -26,5 +28,7 @@ modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= modernc.org/sqlite v1.29.1 h1:19GY2qvWB4VPw0HppFlZCPAbmxFU41r+qjKZQdQ1ryA= modernc.org/sqlite v1.29.1/go.mod h1:hG41jCYxOAOoO6BRK66AdRlmOcDzXf7qnwlwjUIOqa0= +pgregory.net/rapid v1.1.0 h1:CMa0sjHSru3puNx+J0MIAuiiEV4N0qj8/cMWGBBCsjw= +pgregory.net/rapid v1.1.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= zombiezen.com/go/sqlite v1.3.0 h1:98g1gnCm+CNz6AuQHu0gqyw7gR2WU3O3PJufDOStpUs= zombiezen.com/go/sqlite v1.3.0/go.mod h1:yRl27//s/9aXU3RWs8uFQwjkTG9gYNGEls6+6SvrclY= diff --git a/shortcircuit/shortcircuit.go b/shortcircuit/shortcircuit.go new file mode 100644 index 0000000..93ff45b --- /dev/null +++ b/shortcircuit/shortcircuit.go @@ -0,0 +1,79 @@ +package shortcircuit + +import ( + "fmt" + "io" +) + +// Error indicates that a write was not attempted because +// an earlier write failed due to Err. +type Error struct { + Err error +} + +func (a Error) Error() string { + return fmt.Sprintf("writer already failed: %v", a.Err) +} + +// Unwrap returns the error from the earlier failed write. +func (e Error) Unwrap() error { + return e.Err +} + +// Is returns true if target is an Error. +func (e Error) Is(target error) bool { + _, ok := target.(Error) + return ok +} + +type Writer struct { + err error + n int64 + w io.Writer +} + +func EnsureWriter(w io.Writer) *Writer { + scw, ok := w.(*Writer) + if !ok { + scw = NewWriter(w) + } + return scw +} + +func NewWriter(w io.Writer) *Writer { + return &Writer{ + w: w, + } +} + +// Write attempts to write p to the underlying io.Writer if no earlier call to +// Write has failed. It returns the number of bytes written and an error if +// the write failed. If an earlier call did fail, no bytes will be written and +// err will be an Error containing the error from the failed call. +func (w *Writer) Write(p []byte) (n int, err error) { + if w.Failed() { + return 0, Error{Err: w.err} + } + n, err = w.w.Write(p) + w.n += int64(n) + if err != nil { + w.err = err + } + return n, err +} + +// Status returns the total bytes written to the underlying writer, +// and any error resulting from a call to Write. +func (w *Writer) Status() (written int64, err error) { + return w.n, w.err +} + +func (w *Writer) Failed() bool { + return w.err != nil +} + +// ClearError makes this Writer forget any earlier error from Write, so that +// future calls to Write will attempt to write to the underlying io.Writer. +func (s *Writer) ClearError() { + s.err = nil +} diff --git a/shortcircuit/shortcircuit_test.go b/shortcircuit/shortcircuit_test.go new file mode 100644 index 0000000..ef380cf --- /dev/null +++ b/shortcircuit/shortcircuit_test.go @@ -0,0 +1,127 @@ +package shortcircuit_test + +import ( + "bytes" + "errors" + "slices" + "testing" + + "git.codemonkeysoftware.net/b/peachy-go/shortcircuit" + "github.com/shoenig/test" + "github.com/shoenig/test/must" +) + +var ErrFakeFailure = errors.New("operation successfully failed") + +type FailingWriter struct { + fail bool + failAfter int + writeCalls int +} + +func (f *FailingWriter) Fail() { + f.FailAfter(0) +} + +func (f *FailingWriter) FailAfter(nBytes int) { + f.failAfter = nBytes + f.fail = true +} + +func (f *FailingWriter) Succeed() { + f.fail = false +} + +func (f *FailingWriter) Write(p []byte) (int, error) { + f.writeCalls++ + if !f.fail { + return len(p), nil + } + if f.failAfter > len(p) { + f.failAfter -= len(p) + return len(p), nil + } + n := f.failAfter + f.failAfter = 0 + return n, ErrFakeFailure +} + +func (f *FailingWriter) WriteCalls() int { + return f.writeCalls +} + +func TestWriter(t *testing.T) { + t.Run("writes multiple times and counts total bytes written", func(t *testing.T) { + inputs := [][]byte{ + []byte("abcdefghi"), + []byte("jklmnopqr"), + []byte("stuvwxyz"), + } + var buf bytes.Buffer + w := shortcircuit.NewWriter(&buf) + for _, b := range inputs { + n, err := w.Write(slices.Clone(b)) + must.NoError(t, err) + must.False(t, w.Failed()) + must.EqOp(t, len(b), n, must.Sprint("expected Write to return length of byte slice")) + } + allInput := slices.Concat(inputs...) + test.EqFunc(t, allInput, buf.Bytes(), bytes.Equal, test.Sprintf("expected written bytes to equal original bytes")) + n, err := w.Status() + test.EqOp(t, int64(len(allInput)), n, test.Sprint("expected total bytes written to equal total length of inputs")) + test.NoError(t, err) + }) + + t.Run("fails without writing if error was already returned, but tries again after clearing error", func(t *testing.T) { + w := new(FailingWriter) + sc := shortcircuit.NewWriter(w) + b := []byte("abcdefghi") + expectedTotal := int64(len(b)) + + // Succeed + n, err := sc.Write(b) + must.EqOp(t, len(b), n) + must.NoError(t, err) + must.EqOp(t, 1, w.WriteCalls()) + must.False(t, sc.Failed()) + + // Fail + const limit = 3 + w.FailAfter(limit) + expectedTotal += limit + + n, err = sc.Write(b) + must.EqOp(t, limit, n) + must.EqOp(t, ErrFakeFailure, err) + must.True(t, sc.Failed()) + must.EqOp(t, 2, w.WriteCalls()) + total, err := sc.Status() + must.EqOp(t, ErrFakeFailure, err) + must.EqOp(t, expectedTotal, total) + + // Fail again + n, err = sc.Write(b) + must.EqOp(t, 0, n) + must.ErrorIs(t, err, shortcircuit.Error{}) + must.ErrorIs(t, err, ErrFakeFailure) + must.True(t, sc.Failed()) + must.EqOp(t, 2, w.WriteCalls()) + total, err = sc.Status() + must.EqOp(t, ErrFakeFailure, err) + must.EqOp(t, expectedTotal, total) + + // Clear and succeed + w.Succeed() + sc.ClearError() + expectedTotal += int64(len(b)) + + n, err = sc.Write(b) + must.EqOp(t, len(b), n) + must.NoError(t, err) + must.False(t, sc.Failed()) + must.EqOp(t, 3, w.WriteCalls()) + total, err = sc.Status() + must.EqOp(t, expectedTotal, total) + must.NoError(t, err) + }) +}