1
0

init repo

This commit is contained in:
Arpad Ryszka 2025-11-01 03:49:02 +01:00
commit 94a26e5192
13 changed files with 1337 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.cover

25
Makefile Normal file
View File

@ -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

25
escape_test.go Normal file
View File

@ -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())
}
})
}

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module code.squareroundforest.org/arpio/textedit
go 1.25.3

168
indent.go Normal file
View File

@ -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),
)
}

404
indent_test.go Normal file
View File

@ -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 speciesincluding the feathery
fronds of Hypnum cupressiformecarpet 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 speciesincluding the feathery fronds of Hypnum cupressiformecarpet 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 speciesincluding the feathery fronds of Hypnum cupressiformecarpet 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 speciesincluding the feathery fronds of Hypnum cupressiformecarpet 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 speciesincluding the feathery fronds of Hypnum cupressiformecarpet 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())
}
})
})
}

122
lib.go Normal file
View File

@ -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()
}

114
lib_test.go Normal file
View File

@ -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)
}
})
}

1
notes.txt Normal file
View File

@ -0,0 +1 @@
verify slice editing rules

56
replace.go Normal file
View File

@ -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...)
}

329
replace_test.go Normal file
View File

@ -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())
}
})
}

64
sequence.go Normal file
View File

@ -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),
)
}

25
singleline_test.go Normal file
View File

@ -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())
}
})
}