Add editor packages

This commit is contained in:
dwrz
2026-10-26 02:32:03 +00:00
parent 38714f6c80
commit 4ed2ee97e0
23 changed files with 2114 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
package buffer
import (
"bytes"
"fmt"
"log/slog"
"os"
"time"
"code.chimeric.al/dwrz/wen/internal/editor/buffer/line"
)
type Buffer struct {
cursor *Cursor
lines []*line.Line
log *slog.Logger
name string
offset *offset
saved time.Time
}
// offset is the 0-indexed first visible display column (glyph) and row (line)
// of the buffer viewport.
type offset struct {
glyph int
line int
}
func (b *Buffer) CursorLine() *line.Line {
return b.lines[b.cursor.line]
}
func (b *Buffer) Name() string {
return b.name
}
type NewBufferParams struct {
Name string
Log *slog.Logger
}
// Create creates or truncates the named file on disk.
// It returns a Buffer initialized with a single empty line,
// so CursorLine is always valid.
func Create(p NewBufferParams) (*Buffer, error) {
var b = &Buffer{
cursor: &Cursor{},
lines: []*line.Line{{}},
log: p.Log,
name: p.Name,
offset: &offset{},
}
// Create the file.
f, err := os.Create(b.name)
if err != nil {
return nil, fmt.Errorf("failed to create file: %w", err)
}
if err := f.Close(); err != nil {
return nil, fmt.Errorf("failed to close file: %w", err)
}
return b, nil
}
// Open reads the named file into a Buffer. If the file is empty, the Buffer
// is initialized with a single empty line so CursorLine is always valid.
func Open(p NewBufferParams) (*Buffer, error) {
var b = &Buffer{
cursor: &Cursor{},
lines: []*line.Line{},
log: p.Log,
name: p.Name,
offset: &offset{},
}
data, err := os.ReadFile(b.name)
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
// Trim a single trailing newline so saving doesn't add an extra blank line.
data = bytes.TrimSuffix(data, []byte("\n"))
data = bytes.TrimSuffix(data, []byte("\r"))
if len(data) > 0 {
for _, raw := range bytes.Split(data, []byte("\n")) {
raw = bytes.TrimSuffix(raw, []byte("\r"))
b.lines = append(b.lines, line.New(string(raw)))
}
}
// Add a line to write in, if the file is empty.
if len(b.lines) == 0 {
b.lines = append(b.lines, &line.Line{})
}
return b, nil
}
+184
View File
@@ -0,0 +1,184 @@
package buffer
import (
"io"
"log/slog"
"reflect"
"testing"
"code.chimeric.al/dwrz/wen/internal/editor/buffer/line"
)
func newTestBuffer(lines ...string) *Buffer {
var b = &Buffer{
cursor: &Cursor{},
lines: make([]*line.Line, 0, len(lines)),
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
offset: &offset{},
}
for _, s := range lines {
b.lines = append(b.lines, line.New(s))
}
if len(b.lines) == 0 {
b.lines = append(b.lines, &line.Line{})
}
return b
}
func bufferLines(b *Buffer) []string {
var out = make([]string, len(b.lines))
for i, l := range b.lines {
out[i] = l.String()
}
return out
}
type CursorMoveTest struct {
lines []string
action func(b *Buffer)
want Cursor
}
func TestCursorMovement(t *testing.T) {
var tests = []CursorMoveTest{
{
lines: []string{"ab", "cd"},
action: func(b *Buffer) { b.CursorRight() },
want: Cursor{line: 0, index: 1, glyph: 1},
},
{
lines: []string{"a", "b"},
action: func(b *Buffer) {
b.CursorRight()
b.CursorRight()
},
want: Cursor{line: 1, index: 0, glyph: 0},
},
{
lines: []string{"ab", "cd"},
action: func(b *Buffer) {
b.CursorDown()
b.CursorLeft()
},
want: Cursor{line: 0, index: 2, glyph: 2},
},
{
lines: []string{"hello", "hi"},
action: func(b *Buffer) {
b.CursorEnd()
b.CursorDown()
},
want: Cursor{line: 1, index: 2, glyph: 2},
},
{
lines: []string{"abc", "世界"},
action: func(b *Buffer) {
b.CursorRight()
b.CursorDown()
},
want: Cursor{line: 1, index: 0, glyph: 0},
},
{
lines: []string{"abc", "世界"},
action: func(b *Buffer) {
b.CursorEnd()
b.CursorDown()
},
want: Cursor{line: 1, index: 3, glyph: 2},
},
}
for n, test := range tests {
b := newTestBuffer(test.lines...)
test.action(b)
if got := b.Cursor(); got != test.want {
t.Errorf(
"#%d: expected cursor %+v, but got %+v",
n, test.want, got,
)
}
}
}
type EditTest struct {
lines []string
action func(b *Buffer)
wantLines []string
wantCursor Cursor
}
func TestEdit(t *testing.T) {
var tests = []EditTest{
{
lines: []string{""},
action: func(b *Buffer) {
b.Insert('a')
b.Insert('世')
},
wantLines: []string{"a世"},
wantCursor: Cursor{line: 0, index: 4, glyph: 3},
},
{
lines: []string{"hello"},
action: func(b *Buffer) { b.Newline() },
wantLines: []string{"", "hello"},
wantCursor: Cursor{line: 1, index: 0, glyph: 0},
},
{
lines: []string{"abcd"},
action: func(b *Buffer) {
b.CursorRight()
b.CursorRight()
b.Newline()
},
wantLines: []string{"ab", "cd"},
wantCursor: Cursor{line: 1, index: 0, glyph: 0},
},
{
lines: []string{"abcd"},
action: func(b *Buffer) {
b.CursorEnd()
b.Newline()
},
wantLines: []string{"abcd", ""},
wantCursor: Cursor{line: 1, index: 0, glyph: 0},
},
{
lines: []string{"ab", ""},
action: func(b *Buffer) {
b.CursorDown()
b.Backspace()
},
wantLines: []string{"ab"},
wantCursor: Cursor{line: 0, index: 2, glyph: 2},
},
{
lines: []string{"ab", "cd"},
action: func(b *Buffer) {
b.CursorDown()
b.Backspace()
},
wantLines: []string{"abcd"},
wantCursor: Cursor{line: 0, index: 2, glyph: 2},
},
}
for n, test := range tests {
b := newTestBuffer(test.lines...)
test.action(b)
if got := bufferLines(b); !reflect.DeepEqual(got, test.wantLines) {
t.Errorf(
"#%d: expected lines %q, but got %q",
n, test.wantLines, got,
)
}
if got := b.Cursor(); got != test.wantCursor {
t.Errorf(
"#%d: expected cursor %+v, but got %+v",
n, test.wantCursor, got,
)
}
}
}
+218
View File
@@ -0,0 +1,218 @@
package buffer
import (
"log/slog"
"unicode/utf8"
"code.chimeric.al/dwrz/wen/internal/editor/buffer/glyph"
)
// Cursor tracks the position in a Buffer.
// glyph is the 0-indexed terminal display column (tabs are 8 columns, wide
// runes are 2 columns).
// index is the 0-indexed byte offset in the current line's UTF-8 string.
// line is the 0-indexed row in Buffer.lines.
type Cursor struct {
glyph int
index int
line int
}
func (b *Buffer) Cursor() Cursor {
return *b.cursor
}
func (c Cursor) Glyph() int {
return c.glyph
}
func (c Cursor) Index() int {
return c.index
}
func (c Cursor) Line() int {
return c.line
}
func (b *Buffer) CursorDown() {
b.log.Debug("↓ before", slog.Any("cursor", b.cursor))
defer b.log.Debug("↓ after", slog.Any("cursor", b.cursor))
// If we're on the last line, don't move down.
if b.cursor.line >= len(b.lines)-1 {
return
}
// Move to the next line.
b.cursor.line++
// Adjust the column.
var (
line = b.CursorLine()
length = line.Length()
width = line.Width()
)
switch {
// If we've moved to a shorter line, snap to its end.
case b.cursor.glyph > width:
b.cursor.index = length
b.cursor.glyph = width
default:
b.cursor.index, b.cursor.glyph = line.FindGlyphIndex(
b.cursor.glyph,
)
}
}
func (b *Buffer) CursorLeft() {
b.log.Debug("← before", slog.Any("cursor", b.cursor))
defer b.log.Debug("← after", slog.Any("cursor", b.cursor))
switch {
// If we're at the beginning of the line, move to the line above;
// unless we're at the start of the buffer.
case b.cursor.index == 0 && b.cursor.line > 0:
b.cursor.line--
line := b.CursorLine()
b.cursor.index = line.Length()
b.cursor.glyph = line.Width()
// Move left by one rune.
case b.cursor.index > 0:
var (
line = b.CursorLine()
r rune
size int
)
// Reverse until we hit the start of a rune.
for i := b.cursor.index - 1; i >= 0; i-- {
r, size = line.DecodeRune(i)
if r != utf8.RuneError {
b.cursor.index -= size
b.cursor.glyph -= glyph.Width(r)
break
}
}
}
}
func (b *Buffer) CursorRight() {
b.log.Debug("→ before", slog.Any("cursor", b.cursor))
defer b.log.Debug("→ after", slog.Any("cursor", b.cursor))
var (
line = b.CursorLine()
length = line.Length()
)
switch {
// If we're at the end of the line, move to the line below;
// unless we're at the end of the buffer.
case b.cursor.index == length && b.cursor.line < len(b.lines)-1:
b.cursor.line++
b.cursor.index = 0
b.cursor.glyph = 0
// Move the index right by one rune.
case b.cursor.index < length:
r, size := line.DecodeRune(b.cursor.index)
if r == utf8.RuneError {
b.cursor.index++
b.cursor.glyph += glyph.Width(r)
} else {
b.cursor.index += size
b.cursor.glyph += glyph.Width(r)
}
}
}
func (b *Buffer) CursorUp() {
b.log.Debug("↑ before", slog.Any("cursor", b.cursor))
defer b.log.Debug("↑ after", slog.Any("cursor", b.cursor))
// If we're on the first line, don't move up.
if b.cursor.line == 0 {
return
}
b.cursor.line--
// Adjust the column.
var (
line = b.CursorLine()
length = line.Length()
width = line.Width()
)
switch {
case b.cursor.glyph > width:
b.cursor.index = length
b.cursor.glyph = width
default:
b.cursor.index, b.cursor.glyph = line.FindGlyphIndex(
b.cursor.glyph,
)
}
}
func (b *Buffer) PageDown(height int) {
if b.cursor.line+height >= len(b.lines) {
b.cursor.line = len(b.lines) - 1
} else {
b.cursor.line += height
}
var (
line = b.CursorLine()
length = line.Length()
width = line.Width()
)
switch {
case b.cursor.glyph > width:
b.cursor.index = length
b.cursor.glyph = width
default:
b.cursor.index, b.cursor.glyph = line.FindGlyphIndex(
b.cursor.glyph,
)
}
}
func (b *Buffer) PageUp(height int) {
if b.cursor.line-height > 0 {
b.cursor.line -= height
} else {
b.cursor.line = 0
}
var (
line = b.CursorLine()
length = line.Length()
width = line.Width()
)
switch {
case b.cursor.glyph > width:
b.cursor.index = length
b.cursor.glyph = width
default:
b.cursor.index, b.cursor.glyph = line.FindGlyphIndex(
b.cursor.glyph,
)
}
}
func (b *Buffer) CursorHome() {
b.cursor.glyph = 0
b.cursor.index = 0
}
func (b *Buffer) CursorEnd() {
var line = b.CursorLine()
b.cursor.index = line.Length()
b.cursor.glyph = line.Width()
}
+99
View File
@@ -0,0 +1,99 @@
package buffer
import (
"unicode"
"unicode/utf8"
"code.chimeric.al/dwrz/wen/internal/editor/buffer/glyph"
"code.chimeric.al/dwrz/wen/internal/editor/buffer/line"
)
func (b *Buffer) Backspace() {
var cl = b.CursorLine()
switch {
// Don't do anything at the beginning of the buffer.
case b.cursor.line == 0 && b.cursor.index == 0:
// Delete empty lines.
case cl.Length() == 0:
b.lines = append(
b.lines[:b.cursor.line],
b.lines[b.cursor.line+1:]...,
)
b.CursorUp()
b.CursorEnd()
// Append to the previous line.
case b.cursor.line != 0 && b.cursor.index == 0:
index := b.cursor.line
b.CursorUp()
b.CursorEnd()
b.lines[index-1].Append(cl.String())
b.lines = append(
b.lines[:index],
b.lines[index+1:]...,
)
// Delete a rune.
default:
b.CursorLeft()
b.CursorLine().DeleteRune(b.cursor.index)
}
}
func (b *Buffer) Insert(r rune) {
switch {
case r == '\n' || r == '\r':
b.Newline()
case r == '\t':
b.CursorLine().Insert(b.cursor.index, r)
b.cursor.index += utf8.RuneLen(r)
b.cursor.glyph += glyph.Width(r)
// Ignore all other non-printable characters.
case !unicode.IsPrint(r):
return
default:
b.CursorLine().Insert(b.cursor.index, r)
b.cursor.index += utf8.RuneLen(r)
b.cursor.glyph += glyph.Width(r)
}
}
// At the start of a line, create a new line above.
// At the end of the line, create a new line below.
// In the middle of a line, any remaining runes go to the next line.
// TODO: using the cursor index will probably break with marks.
func (b *Buffer) Newline() {
var cl = b.CursorLine()
switch {
case b.cursor.index == 0:
b.lines = append(
b.lines[:b.cursor.line+1],
b.lines[b.cursor.line:]...,
)
b.lines[b.cursor.line] = line.New("")
default:
text := cl.String()
rest := line.New(text[b.cursor.index:])
b.lines = append(
b.lines[:b.cursor.line+1],
b.lines[b.cursor.line:]...,
)
b.lines[b.cursor.line] = line.New(text[:b.cursor.index])
b.lines[b.cursor.line+1] = rest
}
b.CursorDown()
b.CursorHome()
}
+15
View File
@@ -0,0 +1,15 @@
package glyph
import "github.com/mattn/go-runewidth"
// Width returns the terminal column width of a rune.
// Tabs use a fixed width of 8 columns.
// TODO: handle Unicode marks.
func Width(r rune) int {
switch r {
case '\t':
return 8
default:
return runewidth.RuneWidth(r)
}
}
+51
View File
@@ -0,0 +1,51 @@
package line
import "strings"
func (l *Line) Append(s string) {
l.data = l.data + s
}
func (l *Line) DeleteRune(index int) (deleted rune) {
if index < 0 || index >= len(l.data) {
return
}
var str strings.Builder
for i, r := range l.data {
if i == index {
deleted = r
continue
}
str.WriteRune(r)
}
l.data = str.String()
return deleted
}
func (l *Line) Insert(index int, nr rune) {
if index < 0 || index > len(l.data) {
return
}
var str strings.Builder
switch {
// Handle empty lines and the end of the line.
case index == len(l.data):
str.WriteString(l.data)
str.WriteRune(nr)
default:
for i, r := range l.data {
if i == index {
str.WriteRune(nr)
}
str.WriteRune(r)
}
}
l.data = str.String()
}
+131
View File
@@ -0,0 +1,131 @@
package line
import (
"strings"
"unicode/utf8"
"code.chimeric.al/dwrz/wen/internal/editor/buffer/glyph"
)
type Line struct {
data string
}
func New(s string) *Line {
return &Line{data: s}
}
func (l *Line) DecodeRune(index int) (r rune, size int) {
return utf8.DecodeRuneInString(l.data[index:])
}
// FindGlyphIndex returns the byte index and display column (g) for a target
// display column. If target falls inside a multi-column rune, it returns the
// start of that rune. If target is past the end of the line, it returns the
// byte length and total display width of the line.
func (l *Line) FindGlyphIndex(target int) (index, g int) {
var li, lg int
for i, r := range l.data {
// We've reached the target.
if g == target {
return i, g
}
// We've gone too far.
// Return the preceding index and glyph.
if g > target {
return li, lg
}
// Otherwise, we haven't reached the target.
// Save this index and glyph.
li = i
lg = g
// Then increment the glyph.
g += glyph.Width(r)
}
// Target falls in-between the last glyph and the end of the line.
// Use the last glyph.
if target >= lg && target < g {
return li, lg
}
// We weren't able to find the glyph.
// Return the last possible index and glyph.
return len(l.data), g
}
// Length returns the byte length of the line's UTF-8 string.
func (l *Line) Length() int {
return len(l.data)
}
// Render returns the visible substring starting at display column offset and
// fitting within width display columns. Tabs are expanded to 8 spaces. If
// offset splits a multi-column rune, the remaining columns of that rune are
// padded with spaces.
func (l *Line) Render(offset, width int) string {
if offset < 0 || width <= 0 {
return ""
}
var (
text = strings.ReplaceAll(l.data, "\t", " ")
str strings.Builder
)
for _, r := range text {
if r == utf8.RuneError {
continue
}
rw := glyph.Width(r)
if offset > 0 {
offset -= rw
if offset < 0 {
// The offset split a wide rune.
// Pad the remaining column.
pad := -offset
if pad > width {
pad = width
}
for i := 0; i < pad; i++ {
str.WriteRune(' ')
}
width -= pad
offset = 0
}
continue
}
if width-rw < 0 {
break
}
str.WriteRune(r)
width -= rw
}
return str.String()
}
func (l *Line) Runes() []rune {
return []rune(l.data)
}
func (l *Line) String() string {
return l.data
}
// Width returns the terminal display column width of the line.
func (l *Line) Width() (count int) {
for _, r := range l.data {
rw := glyph.Width(r)
if r == utf8.RuneError {
continue
}
count += rw
}
return count
}
+319
View File
@@ -0,0 +1,319 @@
package line
import (
"testing"
)
type GlyphIndexTest struct {
data string
glyph int
index int
target int
}
func TestGlyphIndex(t *testing.T) {
var tests = []GlyphIndexTest{
{
data: "",
index: 0,
glyph: 0,
target: 0,
},
{
data: "\n",
index: 0,
glyph: 0,
target: 0,
},
{
data: "\t🤔",
index: 0,
glyph: 0,
target: 0,
},
{
data: "\t🤔",
index: 0,
glyph: 0,
target: 1,
},
{
data: "\t🤔",
index: 1,
glyph: 8,
target: 8,
},
{
data: "\t🤔**",
index: 5, // \t = 0, 🤔 = 1-4, * = 5
glyph: 10, // \t = 0, 🤔 = 8, * = 10.
target: 10, // *
},
{
data: "\t🤔*",
index: 6, // \t = 0, 🤔 = 1-4, * = 5
glyph: 11, // \t = 0, 🤔 = 8, * = 10.
target: 11, // *
},
{
data: "你是谁?",
index: 3, // 你 size is 3 bytes.
glyph: 2, // 你 width is 2.
target: 2, // 是
},
{
data: "磨杵成针",
index: 12, // 针
glyph: 8, // 针
target: 8, // Doesn't exist; return end of last rune.
},
{
data: "你好!",
index: 6, // !
glyph: 4, // !
target: 5,
},
}
for n, test := range tests {
i, g := New(test.data).FindGlyphIndex(test.target)
if i != test.index {
t.Errorf(
"#%d: expected index %d, but got %d",
n, test.index, i,
)
}
if g != test.glyph {
t.Errorf(
"#%d: expected glyph %d, but got %d",
n, test.glyph, g,
)
}
}
}
type WidthTest struct {
data string
expected int
}
func TestWidth(t *testing.T) {
var tests = []WidthTest{
{
data: "",
expected: 0,
},
{
data: "\n",
expected: 0,
},
{
data: "\t",
expected: 8,
},
{
data: "Hello, World!",
expected: 13,
},
{
data: "Hello, 世界!",
expected: 12,
},
{
data: "💻💩",
expected: 4,
},
}
for _, test := range tests {
if w := New(test.data).Width(); w != test.expected {
t.Errorf(
"expected width %d, but got %d",
test.expected, w,
)
}
}
}
type RenderTest struct {
data string
offset int
width int
expected string
}
func TestRender(t *testing.T) {
var tests = []RenderTest{
{
data: "",
offset: 0,
width: 10,
expected: "",
},
{
data: "hello",
offset: 0,
width: 10,
expected: "hello",
},
{
data: "hello",
offset: 2,
width: 2,
expected: "ll",
},
{
data: "\ta",
offset: 0,
width: 9,
expected: " a",
},
{
data: "世界",
offset: 0,
width: 3,
expected: "世",
},
{
data: "世界",
offset: 1,
width: 3,
expected: " 界",
},
{
data: "hello",
offset: -1,
width: 5,
expected: "",
},
{
data: "hello",
offset: 0,
width: 0,
expected: "",
},
}
for n, test := range tests {
got := New(test.data).Render(test.offset, test.width)
if got != test.expected {
t.Errorf(
"#%d: expected %q, but got %q",
n, test.expected, got,
)
}
}
}
type InsertTest struct {
data string
index int
r rune
expected string
}
func TestInsert(t *testing.T) {
var tests = []InsertTest{
{
data: "",
index: 0,
r: 'a',
expected: "a",
},
{
data: "bc",
index: 0,
r: 'a',
expected: "abc",
},
{
data: "ac",
index: 1,
r: 'b',
expected: "abc",
},
{
data: "ab",
index: 2,
r: 'c',
expected: "abc",
},
{
data: "世",
index: 3,
r: '界',
expected: "世界",
},
}
for n, test := range tests {
l := New(test.data)
l.Insert(test.index, test.r)
if got := l.String(); got != test.expected {
t.Errorf(
"#%d: expected %q, but got %q",
n, test.expected, got,
)
}
}
}
type DeleteRuneTest struct {
data string
index int
deleted rune
expected string
}
func TestDeleteRune(t *testing.T) {
var tests = []DeleteRuneTest{
{
data: "",
index: 0,
deleted: 0,
expected: "",
},
{
data: "abc",
index: 0,
deleted: 'a',
expected: "bc",
},
{
data: "abc",
index: 1,
deleted: 'b',
expected: "ac",
},
{
data: "世界",
index: 0,
deleted: '世',
expected: "界",
},
{
data: "世界",
index: 3,
deleted: '界',
expected: "世",
},
}
for n, test := range tests {
l := New(test.data)
r := l.DeleteRune(test.index)
if r != test.deleted {
t.Errorf(
"#%d: expected deleted %q, but got %q",
n, test.deleted, r,
)
}
if got := l.String(); got != test.expected {
t.Errorf(
"#%d: expected %q, but got %q",
n, test.expected, got,
)
}
}
}
+51
View File
@@ -0,0 +1,51 @@
package buffer
// Output holds the visible lines for a terminal viewport and the cursor
// position adjusted for scrolling.
// Unlike Cursor, Glyph and Line are 1-indexed terminal coordinates for ANSI
// cursor positioning.
type Output struct {
Glyph int
Line int
Lines []string
}
// TODO: wrap lines.
// TODO: add highlighting.
func (b *Buffer) Render(height, width int) *Output {
// Vertical scroll.
if b.cursor.line < b.offset.line {
b.offset.line = b.cursor.line
}
if b.cursor.line >= height+b.offset.line {
b.offset.line = b.cursor.line - height + 1
}
// Horizontal scroll.
if b.cursor.glyph < b.offset.glyph {
b.offset.glyph = b.cursor.glyph
}
if b.cursor.glyph >= width+b.offset.glyph {
b.offset.glyph = b.cursor.glyph - width + 1
}
// Generate lines.
var lines = []string{}
for i := b.offset.line; i < height+b.offset.line; i++ {
// Return empty lines for indexes past the buffer's lines.
if i >= len(b.lines) {
lines = append(lines, "")
continue
}
line := b.lines[i].Render(b.offset.glyph, width)
lines = append(lines, line)
}
return &Output{
// Terminals are 1-indexed.
Glyph: b.cursor.glyph - b.offset.glyph + 1,
Line: b.cursor.line - b.offset.line + 1,
Lines: lines,
}
}
+66
View File
@@ -0,0 +1,66 @@
package buffer
import (
"bytes"
"fmt"
"os"
"time"
)
// TODO: save to temporary file, then move over current file.
// TODO: autosave; track dirty buffer.
func (b *Buffer) Save() error {
var buf bytes.Buffer
switch len(b.lines) {
case 1:
if l := b.lines[0]; l.Length() != 0 {
if _, err := buf.WriteString(l.String()); err != nil {
return fmt.Errorf(
"failed to write to buffer: %v", err,
)
}
if _, err := buf.WriteRune('\n'); err != nil {
return fmt.Errorf(
"failed to write to buffer: %v", err,
)
}
}
default:
for _, l := range b.lines {
if _, err := buf.WriteString(l.String()); err != nil {
return fmt.Errorf(
"failed to write to buffer: %v", err,
)
}
if _, err := buf.WriteRune('\n'); err != nil {
return fmt.Errorf(
"failed to write to buffer: %v", err,
)
}
}
}
if err := os.WriteFile(
b.name,
buf.Bytes(),
0666,
); err != nil {
return fmt.Errorf("failed to write file: %v", err)
}
b.saved = time.Now()
return nil
}
func (b *Buffer) SaveAs(name string) error {
old := b.name
b.name = name
if err := b.Save(); err != nil {
b.name = old
return fmt.Errorf("failed to save file as %s: %w", name, err)
}
return nil
}