commit 94a26e51923d7f3b17a6fa4bb900a12e1a5cfe85 Author: Arpad Ryszka Date: Sat Nov 1 03:49:02 2025 +0100 init repo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ebf0f2e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.cover diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e98dea1 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +sources = $(shell find . -name "*.go") + +default: build + +build: $(sources) + go build + +fmt: $(sources) + go fmt + +check: $(sources) + go test -count 1 + +.cover: $(sources) + go test -count 1 -coverprofile .cover + +cover: .cover + go tool cover -func .cover + +showcover: .cover + go tool cover -html .cover + +clean: + go clean + rm .cover diff --git a/escape_test.go b/escape_test.go new file mode 100644 index 0000000..5296cb9 --- /dev/null +++ b/escape_test.go @@ -0,0 +1,25 @@ +package textedit_test + +import ( + "bytes" + "code.squareroundforest.org/arpio/textedit" + "testing" +) + +func TestEscape(t *testing.T) { + t.Run("basic", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Escape('\\', '\n', '\t', '"')) + if _, err := w.Write([]byte("foo\nbar\t\"baz\"")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "foo\\\nbar\\\t\\\"baz\\\"" { + t.Fatal(b.String()) + } + }) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4960fe4 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module code.squareroundforest.org/arpio/textedit + +go 1.25.3 diff --git a/indent.go b/indent.go new file mode 100644 index 0000000..e603ff4 --- /dev/null +++ b/indent.go @@ -0,0 +1,168 @@ +package textedit + +import "unicode" + +const nonbreakSpace = '\u00a0' + +type wrapIndentState struct { + currentWord []rune + currentLineLength int + multipleLines bool +} + +func indentLength(i []rune) int { + var l int + for _, ii := range i { + if ii == '\t' { + l += 8 + continue + } + + l++ + } + + return l +} + +func wrapIndentEdit(first, rest []rune, firstWidth, restWidth int) func(rune, wrapIndentState) ([]rune, wrapIndentState) { + firstIndentLength := indentLength(first) + restIndentLength := indentLength(rest) + return func(r rune, state wrapIndentState) ([]rune, wrapIndentState) { + var ret []rune + indent := first + il := firstIndentLength + width := firstWidth + if state.multipleLines { + indent = rest + il = restIndentLength + width = restWidth + } + + nw := width <= 0 + cl := state.currentLineLength + wl := len(state.currentWord) + nl := r == '\n' + ws := unicode.IsSpace(r) && r != nonbreakSpace + if nw && nl && wl > 0 { + if cl == 0 { + ret = append(ret, indent...) + } + + if cl > 0 { + ret = append(ret, ' ') + } + + ret = append(ret, state.currentWord...) + ret = append(ret, '\n') + state.currentWord = nil + state.currentLineLength = 0 + state.multipleLines = true + return ret, state + } + + if nw && nl { + ret = append(ret, '\n') + state.currentLineLength = 0 + state.multipleLines = true + return ret, state + } + + if nw && ws && wl > 0 { + if cl == 0 { + ret = append(ret, indent...) + } + + if cl > 0 { + ret = append(ret, ' ') + state.currentLineLength++ + } + + ret = append(ret, state.currentWord...) + state.currentLineLength += wl + state.currentWord = nil + return ret, state + } + + if nw && ws { + return nil, state + } + + if nw { + state.currentWord = append(state.currentWord, r) + return nil, state + } + + if ws && cl > 0 && cl+wl+il+1 > width && wl > 0 { + ret = append(ret, '\n') + ret = append(ret, rest...) + ret = append(ret, state.currentWord...) + state.currentLineLength = wl + state.multipleLines = true + state.currentWord = nil + return ret, state + } + + if ws && cl > 0 && cl+wl+il+1 > width { + ret = append(ret, '\n') + state.currentLineLength = 0 + state.multipleLines = true + return ret, state + } + + if ws && cl > 0 && wl > 0 { + ret = append(ret, ' ') + ret = append(ret, state.currentWord...) + state.currentLineLength++ + state.currentLineLength += wl + state.currentWord = nil + return ret, state + } + + if ws && wl > 0 { + ret = append(ret, indent...) + ret = append(ret, state.currentWord...) + state.currentLineLength += wl + state.currentWord = nil + return ret, state + } + + if ws { + return nil, state + } + + state.currentWord = append(state.currentWord, r) + return nil, state + } +} + +func wrapIndentRelease(first, rest []rune) func(wrapIndentState) []rune { + return func(state wrapIndentState) []rune { + if len(state.currentWord) == 0 { + return nil + } + + var ret []rune + indent := first + if state.multipleLines { + indent = rest + } + + if state.currentLineLength == 0 { + ret = append(ret, indent...) + } + + if state.currentLineLength > 0 { + ret = append(ret, ' ') + } + + ret = append(ret, state.currentWord...) + return ret + } +} + +func wrapIndent(first, rest []rune, firstWidth, restWidth int) Editor { + return Func( + wrapIndentEdit(first, rest, firstWidth, restWidth), + wrapIndentRelease(first, rest), + ) +} diff --git a/indent_test.go b/indent_test.go new file mode 100644 index 0000000..d6bc8d4 --- /dev/null +++ b/indent_test.go @@ -0,0 +1,404 @@ +package textedit_test + +import ( + "bytes" + "code.squareroundforest.org/arpio/textedit" + "testing" +) + +func TestIndent(t *testing.T) { + t.Run("no indent no wrap", func(t *testing.T) { + t.Run("empty", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Indent("", "")) + if _, err := w.Write(nil); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "" { + t.Fatal(b.String()) + } + }) + + t.Run("new line", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Indent("", "")) + if _, err := w.Write([]byte("\n")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "\n" { + t.Fatal(b.String()) + } + }) + + t.Run("basic", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Indent("", "")) + if _, err := w.Write([]byte("foo bar\n baz\nqux quux")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "foo bar\nbaz\nqux quux" { + t.Fatal(b.String()) + } + }) + + t.Run("closing new line", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Indent("", "")) + if _, err := w.Write([]byte("foo bar\n baz\nqux quux\n")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "foo bar\nbaz\nqux quux\n" { + t.Fatal(b.String()) + } + }) + + t.Run("word mid line", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Indent("", "")) + if _, err := w.Write([]byte("foo bar bar foo\n baz\nqux quux\n")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "foo bar bar foo\nbaz\nqux quux\n" { + t.Fatal(b.String()) + } + }) + + t.Run("indent last word", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Indent("xxxx", "xxxx")) + if _, err := w.Write([]byte("foo bar bar foo\n baz\nqux quux\nfoo")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "xxxxfoo bar bar foo\nxxxxbaz\nxxxxqux quux\nxxxxfoo" { + t.Fatal(b.String()) + } + }) + }) + + t.Run("indent no wrap", func(t *testing.T) { + t.Run("first and rest same", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Indent("xx", "xx")) + if _, err := w.Write([]byte("foo bar\n baz\nqux quux\n")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "xxfoo bar\nxxbaz\nxxqux quux\n" { + t.Fatal(b.String()) + } + }) + + t.Run("first out", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Indent("", "xx")) + if _, err := w.Write([]byte("foo bar\n baz\nqux quux\n")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "foo bar\nxxbaz\nxxqux quux\n" { + t.Fatal(b.String()) + } + }) + + t.Run("first in", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Indent("xx", "")) + if _, err := w.Write([]byte("foo bar\n baz\nqux quux\n")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "xxfoo bar\nbaz\nqux quux\n" { + t.Fatal(b.String()) + } + }) + + t.Run("first out offset", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Indent("xx", "xxxx")) + if _, err := w.Write([]byte("foo bar\n baz\nqux quux\n")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "xxfoo bar\nxxxxbaz\nxxxxqux quux\n" { + t.Fatal(b.String()) + } + }) + + t.Run("first in offset", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Indent("xxxx", "xx")) + if _, err := w.Write([]byte("foo bar\n baz\nqux quux\n")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "xxxxfoo bar\nxxbaz\nxxqux quux\n" { + t.Fatal(b.String()) + } + }) + }) + + t.Run("wrap", func(t *testing.T) { + t.Run("same first and rest", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Wrap(9, 9)) + if _, err := w.Write([]byte("foo bar\n baz\nqux quux\n")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "foo bar\nbaz qux\nquux" { + t.Fatal(b.String()) + } + }) + + t.Run("shorter first", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Wrap(5, 9)) + if _, err := w.Write([]byte("foo bar\n baz\nqux quux\n")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "foo\nbar baz\nqux quux" { + t.Fatal(b.String()) + } + }) + + t.Run("longer first", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Wrap(9, 5)) + if _, err := w.Write([]byte("foo bar\n baz\nqux quux\n")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "foo bar\nbaz\nqux\nquux" { + t.Fatal(b.String()) + } + }) + + t.Run("word longer than width", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Wrap(2, 2)) + if _, err := w.Write([]byte("foo bar\n baz\nqux quux\n")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "foo\nbar\nbaz\nqux\nquux" { + t.Fatal(b.String()) + } + }) + }) + + t.Run("indent and wrap", func(t *testing.T) { + const text = ` + Walking through the mixed forests of Brandenburg in early autumn, one notices the dominant + presence of Scots pine (Pinus sylvestris) interspersed with sessile oak (Quercus petraea) and + silver birch (Betula pendula), their canopies creating a mosaic of light and shadow on the + forest floor. The sandy, acidic soils typical of this region support a ground layer rich in + ericaceous plants, particularly bilberry (Vaccinium myrtillus) with its dark-green oval leaves + now tinged with burgundy, and the occasional patch of heather (Calluna vulgaris) persisting in + sunnier clearings. Closer inspection of the understory reveals common wood sorrel (Oxalis + acetosella) thriving in moister pockets, while various moss species—including the feathery + fronds of Hypnum cupressiforme—carpet fallen logs in varying stages of decay. The drier sections + host wavy hair-grass (Deschampsia flexuosa) in delicate tufts, and where old pines have been + felled, pioneering stands of downy birch and rowan (Sorbus aucuparia) compete for space, their + growth marking the forest's continuous cycle of regeneration in this characteristically glacial + landscape of the North European Plain. + ` + + t.Run("uniform", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.WrapIndent(" ", " ", 120, 120)) + if _, err := w.Write([]byte(text)); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + const expect = ` + Walking through the mixed forests of Brandenburg in early autumn, one notices the dominant presence of Scots pine + (Pinus sylvestris) interspersed with sessile oak (Quercus petraea) and silver birch (Betula pendula), their canopies + creating a mosaic of light and shadow on the forest floor. The sandy, acidic soils typical of this region support a + ground layer rich in ericaceous plants, particularly bilberry (Vaccinium myrtillus) with its dark-green oval leaves + now tinged with burgundy, and the occasional patch of heather (Calluna vulgaris) persisting in sunnier clearings. + Closer inspection of the understory reveals common wood sorrel (Oxalis acetosella) thriving in moister pockets, + while various moss species—including the feathery fronds of Hypnum cupressiforme—carpet fallen logs in varying + stages of decay. The drier sections host wavy hair-grass (Deschampsia flexuosa) in delicate tufts, and where old + pines have been felled, pioneering stands of downy birch and rowan (Sorbus aucuparia) compete for space, their + growth marking the forest's continuous cycle of regeneration in this characteristically glacial landscape of the + North European Plain.` + + if "\n"+b.String() != expect { + t.Fatal("\n" + b.String()) + } + }) + + t.Run("classic", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.WrapIndent(" ", "", 120, 120)) + if _, err := w.Write([]byte(text)); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + const expect = ` + Walking through the mixed forests of Brandenburg in early autumn, one notices the dominant presence of Scots pine +(Pinus sylvestris) interspersed with sessile oak (Quercus petraea) and silver birch (Betula pendula), their canopies +creating a mosaic of light and shadow on the forest floor. The sandy, acidic soils typical of this region support a +ground layer rich in ericaceous plants, particularly bilberry (Vaccinium myrtillus) with its dark-green oval leaves now +tinged with burgundy, and the occasional patch of heather (Calluna vulgaris) persisting in sunnier clearings. Closer +inspection of the understory reveals common wood sorrel (Oxalis acetosella) thriving in moister pockets, while various +moss species—including the feathery fronds of Hypnum cupressiforme—carpet fallen logs in varying stages of decay. The +drier sections host wavy hair-grass (Deschampsia flexuosa) in delicate tufts, and where old pines have been felled, +pioneering stands of downy birch and rowan (Sorbus aucuparia) compete for space, their growth marking the forest's +continuous cycle of regeneration in this characteristically glacial landscape of the North European Plain.` + + if "\n"+b.String() != expect { + t.Fatal("\n" + b.String()) + } + }) + + t.Run("indent out", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.WrapIndent("", " ", 120, 120)) + if _, err := w.Write([]byte(text)); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + const expect = ` +Walking through the mixed forests of Brandenburg in early autumn, one notices the dominant presence of Scots pine (Pinus + sylvestris) interspersed with sessile oak (Quercus petraea) and silver birch (Betula pendula), their canopies + creating a mosaic of light and shadow on the forest floor. The sandy, acidic soils typical of this region support a + ground layer rich in ericaceous plants, particularly bilberry (Vaccinium myrtillus) with its dark-green oval leaves + now tinged with burgundy, and the occasional patch of heather (Calluna vulgaris) persisting in sunnier clearings. + Closer inspection of the understory reveals common wood sorrel (Oxalis acetosella) thriving in moister pockets, + while various moss species—including the feathery fronds of Hypnum cupressiforme—carpet fallen logs in varying + stages of decay. The drier sections host wavy hair-grass (Deschampsia flexuosa) in delicate tufts, and where old + pines have been felled, pioneering stands of downy birch and rowan (Sorbus aucuparia) compete for space, their + growth marking the forest's continuous cycle of regeneration in this characteristically glacial landscape of the + North European Plain.` + + if "\n"+b.String() != expect { + t.Fatal("\n" + b.String()) + } + }) + + t.Run("indent out with same width", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.WrapIndent("", " ", 116, 120)) + if _, err := w.Write([]byte(text)); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + const expect = ` +Walking through the mixed forests of Brandenburg in early autumn, one notices the dominant presence of Scots pine + (Pinus sylvestris) interspersed with sessile oak (Quercus petraea) and silver birch (Betula pendula), their canopies + creating a mosaic of light and shadow on the forest floor. The sandy, acidic soils typical of this region support a + ground layer rich in ericaceous plants, particularly bilberry (Vaccinium myrtillus) with its dark-green oval leaves + now tinged with burgundy, and the occasional patch of heather (Calluna vulgaris) persisting in sunnier clearings. + Closer inspection of the understory reveals common wood sorrel (Oxalis acetosella) thriving in moister pockets, + while various moss species—including the feathery fronds of Hypnum cupressiforme—carpet fallen logs in varying + stages of decay. The drier sections host wavy hair-grass (Deschampsia flexuosa) in delicate tufts, and where old + pines have been felled, pioneering stands of downy birch and rowan (Sorbus aucuparia) compete for space, their + growth marking the forest's continuous cycle of regeneration in this characteristically glacial landscape of the + North European Plain.` + + if "\n"+b.String() != expect { + t.Fatal("\n" + b.String()) + } + }) + + t.Run("tab is 8", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.WrapIndent("\t", "", 12, 12)) + if _, err := w.Write([]byte("foo bar\n baz\nqux quux\n")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "\tfoo\nbar baz qux\nquux" { + t.Fatal(b.String()) + } + }) + }) +} diff --git a/lib.go b/lib.go new file mode 100644 index 0000000..d03ebab --- /dev/null +++ b/lib.go @@ -0,0 +1,122 @@ +package textedit + +import "io" + +type Editor interface { + Edit(r rune, state any) ([]rune, any) + ReleaseState(state any) []rune +} + +type editorFunc[S any] struct { + edit func(rune, S) ([]rune, S) + releaseState func(S) []rune +} + +type Writer struct { + out io.Writer + editor Editor + state any + err error +} + +func (e editorFunc[S]) Edit(r rune, state any) ([]rune, any) { + s, _ := state.(S) + return e.edit(r, s) +} + +func (e editorFunc[S]) ReleaseState(state any) []rune { + s, _ := state.(S) + r := e.releaseState(s) + return r +} + +func Func[S any](edit func(r rune, state S) ([]rune, S), releaseState func(S) []rune) Editor { + if edit == nil { + edit = func(r rune, s S) ([]rune, S) { return []rune{r}, s } + } + + if releaseState == nil { + releaseState = func(S) []rune { return nil } + } + + return editorFunc[S]{edit: edit, releaseState: releaseState} +} + +func Replace(a ...string) Editor { + return replace(a...) +} + +func Escape(esc rune, chars ...rune) Editor { + return escape(esc, chars...) +} + +func Indent(first, rest string) Editor { + return wrapIndent([]rune(first), []rune(rest), 0, 0) +} + +func Wrap(firstWidth, restWidth int) Editor { + return wrapIndent(nil, nil, firstWidth, restWidth) +} + +func WrapIndent(firstIndent, restIndent string, firstWidth, restWidth int) Editor { + return wrapIndent([]rune(firstIndent), []rune(restIndent), firstWidth, restWidth) +} + +func SingleLine() Editor { + return sequence( + replace("\n", " "), + wrapIndent(nil, nil, 0, 0), + ) +} + +func New(out io.Writer, e ...Editor) *Writer { + return &Writer{ + out: out, + editor: sequence(e...), + } +} + +func (w *Writer) write(r []rune) error { + if w.err != nil { + return w.err + } + + for _, ri := range r { + rr, s := w.editor.Edit(ri, w.state) + if _, err := w.out.Write([]byte(string(rr))); err != nil { + w.err = err + return w.err + } + + w.state = s + } + + return nil +} + +func (w *Writer) flush() error { + if w.err != nil { + return w.err + } + + r := w.editor.ReleaseState(w.state) + w.state = nil + if _, err := w.out.Write([]byte(string(r))); err != nil { + w.err = err + return w.err + } + + return nil +} + +func (w *Writer) Write(p []byte) (int, error) { + return len(p), w.write([]rune(string(p))) +} + +func (w *Writer) WriteRune(r rune) (int, error) { + return len([]byte(string(r))), w.write([]rune{r}) +} + +func (w *Writer) Flush() error { + return w.flush() +} diff --git a/lib_test.go b/lib_test.go new file mode 100644 index 0000000..19c60d7 --- /dev/null +++ b/lib_test.go @@ -0,0 +1,114 @@ +package textedit_test + +import ( + "bytes" + "code.squareroundforest.org/arpio/textedit" + "errors" + "io" + "testing" +) + +var errTest = errors.New("test") + +type failingWriter struct { + out io.Writer + fail bool +} + +func (w *failingWriter) Write(p []byte) (int, error) { + if w.fail { + return 0, errTest + } + + return w.out.Write(p) +} + +func TestNoop(t *testing.T) { + t.Run("editor", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Func(nil, func(int) []rune { return nil })) + if _, err := w.Write([]byte("foo bar baz")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "foo bar baz" { + t.Fatal(b.String()) + } + }) + + t.Run("release", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Func(func(r rune, s int) ([]rune, int) { return []rune{r}, 9 }, nil)) + if _, err := w.Write([]byte("foo bar baz")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "foo bar baz" { + t.Fatal(b.String()) + } + }) +} + +func TestWriteRune(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Replace("o", "e")) + for _, r := range []rune("foo bar baz") { + if _, err := w.WriteRune(r); err != nil { + t.Fatal(err) + } + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "fee bar baz" { + t.Fatal(b.String()) + } +} + +func TestFailingWriter(t *testing.T) { + t.Run("after write", func(t *testing.T) { + var b bytes.Buffer + fw := failingWriter{out: &b} + w := textedit.New(&fw, textedit.Replace("o", "e")) + if _, err := w.Write([]byte("foo ")); err != nil { + t.Fatal(err) + } + + fw.fail = true + if _, err := w.Write([]byte("bar")); !errors.Is(err, errTest) { + t.Fatal("failed to fail with the right error", err) + } + + if _, err := w.Write([]byte("bar")); !errors.Is(err, errTest) { + t.Fatal("failed to fail with the right error", err) + } + + if err := w.Flush(); !errors.Is(err, errTest) { + t.Fatal("failed to fail with the right error", err) + } + }) + + t.Run("after flush", func(t *testing.T) { + var b bytes.Buffer + fw := failingWriter{out: &b} + w := textedit.New(&fw, textedit.Replace("o", "e")) + if _, err := w.Write([]byte("foo bar")); err != nil { + t.Fatal(err) + } + + fw.fail = true + if err := w.Flush(); !errors.Is(err, errTest) { + t.Fatal("failed to fail with the right error", err) + } + }) +} diff --git a/notes.txt b/notes.txt new file mode 100644 index 0000000..bc1ac14 --- /dev/null +++ b/notes.txt @@ -0,0 +1 @@ +verify slice editing rules diff --git a/replace.go b/replace.go new file mode 100644 index 0000000..7aa6c54 --- /dev/null +++ b/replace.go @@ -0,0 +1,56 @@ +package textedit + +func replaceEdit(match, replacement []rune) func(rune, []rune) ([]rune, []rune) { + return func(r rune, state []rune) ([]rune, []rune) { + state = append(state, r) + if len(state) > len(match) { + return state, nil + } + + if r != match[len(state)-1] { + return state, nil + } + + if len(state) == len(match) { + return replacement, nil + } + + return nil, state + } +} + +func replaceRelease(state []rune) []rune { + return state +} + +func replaceOne(match, replacement string) Editor { + return Func( + replaceEdit([]rune(match), []rune(replacement)), + replaceRelease, + ) +} + +func replace(a ...string) Editor { + if len(a)%2 != 0 { + a = append(a, "") + } + + var e []Editor + for i := 0; i < len(a); i += 2 { + e = append( + e, + replaceOne(a[i], a[i+1]), + ) + } + + return sequence(e...) +} + +func escape(esc rune, chars ...rune) Editor { + r := []string{string(esc), string([]rune{esc, esc})} + for _, c := range chars { + r = append(r, string(c), string([]rune{esc, c})) + } + + return replace(r...) +} diff --git a/replace_test.go b/replace_test.go new file mode 100644 index 0000000..9f253a3 --- /dev/null +++ b/replace_test.go @@ -0,0 +1,329 @@ +package textedit_test + +import ( + "bytes" + "code.squareroundforest.org/arpio/textedit" + "testing" +) + +func TestReplace(t *testing.T) { + t.Run("empty and no match", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Replace()) + if _, err := w.Write(nil); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "" { + t.Fatal(b.String()) + } + }) + + t.Run("empty and empty match", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Replace("r")) + if _, err := w.Write(nil); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "" { + t.Fatal(b.String()) + } + }) + + t.Run("empty one single char match", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Replace("r", "z")) + if _, err := w.Write(nil); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "" { + t.Fatal(b.String()) + } + }) + + t.Run("empty multiple single char matches", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Replace("o", "e", "r", "z")) + if _, err := w.Write(nil); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "" { + t.Fatal(b.String()) + } + }) + + t.Run("empty one multi-char match", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Replace("oo", "ee")) + if _, err := w.Write(nil); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "" { + t.Fatal(b.String()) + } + }) + + t.Run("empty multiple multi-char matches", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Replace("oo", "ee", "ar", "az")) + if _, err := w.Write(nil); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "" { + t.Fatal(b.String()) + } + }) + + t.Run("no match empty match", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Replace("r")) + if _, err := w.Write([]byte("foo")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "foo" { + t.Fatal(b.String()) + } + }) + + t.Run("no match one single char match", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Replace("r", "z")) + if _, err := w.Write([]byte("foo")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "foo" { + t.Fatal(b.String()) + } + }) + + t.Run("no match multiple single char matches", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Replace("b", "f", "r", "z")) + if _, err := w.Write([]byte("foo")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "foo" { + t.Fatal(b.String()) + } + }) + + t.Run("no match one multi-char match", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Replace("bar", "baz")) + if _, err := w.Write([]byte("foo")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "foo" { + t.Fatal(b.String()) + } + }) + + t.Run("no match multiple multi-char matches", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Replace("bar", "baz", "qux", "quuz")) + if _, err := w.Write([]byte("foo")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "foo" { + t.Fatal(b.String()) + } + }) + + t.Run("match empty match", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Replace("a")) + if _, err := w.Write([]byte("bar")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "br" { + t.Fatal(b.String()) + } + }) + + t.Run("match one single char match", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Replace("r", "z")) + if _, err := w.Write([]byte("bar")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "baz" { + t.Fatal(b.String()) + } + }) + + t.Run("match multiple single char matches", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Replace("o", "e", "r", "z")) + if _, err := w.Write([]byte("foo bar")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "fee baz" { + t.Fatal(b.String()) + } + }) + + t.Run("match one multi-char match", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Replace("oo", "ee")) + if _, err := w.Write([]byte("foo bar")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "fee bar" { + t.Fatal(b.String()) + } + }) + + t.Run("match multiple multi-char matches", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Replace("oo", "ee", "ar", "az")) + if _, err := w.Write([]byte("foo bar")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "fee baz" { + t.Fatal(b.String()) + } + }) + + t.Run("inverse order", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Replace("ar", "az", "oo", "ee")) + if _, err := w.Write([]byte("foo bar")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "fee baz" { + t.Fatal(b.String()) + } + }) + + t.Run("mismatch after partial match", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Replace("foo", "bar")) + if _, err := w.Write([]byte("fox baz")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "fox baz" { + t.Fatal(b.String()) + } + }) + + t.Run("empty match", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Replace("", "bar")) + if _, err := w.Write([]byte("foo baz")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "foo baz" { + t.Fatal(b.String()) + } + }) + + t.Run("delete", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.Replace("bar", "")) + if _, err := w.Write([]byte("foo bar")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "foo " { + t.Fatal(b.String()) + } + }) +} diff --git a/sequence.go b/sequence.go new file mode 100644 index 0000000..e70398f --- /dev/null +++ b/sequence.go @@ -0,0 +1,64 @@ +package textedit + +func sequenceEdit(e []Editor) func(rune, []any) ([]rune, []any) { + return func(r rune, s []any) ([]rune, []any) { + var sr []any + rr := []rune{r} + for i, ei := range e { + var si any + if len(s) > i { + si = s[i] + } + + rin := rr + rr = nil + for _, ri := range rin { + var rout []rune + rout, si = ei.Edit(ri, si) + rr = append(rr, rout...) + } + + sr = append(sr, si) + } + + return rr, sr + } +} + +func sequenceRelease(e []Editor) func([]any) []rune { + return func(s []any) []rune { + var rr []rune + for i, ei := range e { + var r []rune + if i < len(s) { + r = ei.ReleaseState(s[i]) + } + + for _, ri := range r { + var ( + rri []rune + ssi []any + ) + + if i < len(s) { + ssi = s[i:] + } + + rri, ssi = sequenceEdit(e[i+1:])(ri, ssi) + rr = append(rr, rri...) + if i < len(s) { + s = append(s[:i], ssi...) + } + } + } + + return rr + } +} + +func sequence(e ...Editor) Editor { + return Func( + sequenceEdit(e), + sequenceRelease(e), + ) +} diff --git a/singleline_test.go b/singleline_test.go new file mode 100644 index 0000000..b5885c0 --- /dev/null +++ b/singleline_test.go @@ -0,0 +1,25 @@ +package textedit_test + +import ( + "bytes" + "code.squareroundforest.org/arpio/textedit" + "testing" +) + +func TestSingleLine(t *testing.T) { + t.Run("basic", func(t *testing.T) { + var b bytes.Buffer + w := textedit.New(&b, textedit.SingleLine()) + if _, err := w.Write([]byte("\nfoo bar\n\nbaz \nqux quux\n")); err != nil { + t.Fatal(err) + } + + if err := w.Flush(); err != nil { + t.Fatal(err) + } + + if b.String() != "foo bar baz qux quux" { + t.Fatal(b.String()) + } + }) +}