From 003921c2d5f9434170768ad3ab55d60cb2fb026e Mon Sep 17 00:00:00 2001 From: jdl Date: Wed, 31 Mar 2021 10:27:54 +0200 Subject: [PATCH] Intial commit --- flock/flock.go | 31 ++++++++++++++++++++ flock/flock_test.go | 43 +++++++++++++++++++++++++++ fsutil/fsutil.go | 53 +++++++++++++++++++++++++++++++++ fsutil/fsutil_test.go | 62 +++++++++++++++++++++++++++++++++++++++ go.mod | 5 ++++ go.sum | 4 +++ tui/escapes.go | 10 +++++++ tui/input.go | 54 ++++++++++++++++++++++++++++++++++ tui/output.go | 35 ++++++++++++++++++++++ tui/stringutil.go | 68 +++++++++++++++++++++++++++++++++++++++++++ tui/tui.go | 49 +++++++++++++++++++++++++++++++ tui/util.go | 23 +++++++++++++++ tui/wordwrap.go | 60 ++++++++++++++++++++++++++++++++++++++ tui/wordwrap_test.go | 56 +++++++++++++++++++++++++++++++++++ 14 files changed, 553 insertions(+) create mode 100644 flock/flock.go create mode 100644 flock/flock_test.go create mode 100644 fsutil/fsutil.go create mode 100644 fsutil/fsutil_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 tui/escapes.go create mode 100644 tui/input.go create mode 100644 tui/output.go create mode 100644 tui/stringutil.go create mode 100644 tui/tui.go create mode 100644 tui/util.go create mode 100644 tui/wordwrap.go create mode 100644 tui/wordwrap_test.go diff --git a/flock/flock.go b/flock/flock.go new file mode 100644 index 0000000..7bd3d1b --- /dev/null +++ b/flock/flock.go @@ -0,0 +1,31 @@ +// The flock package provides a file-system mediated locking mechanism on linux +// using the `flock` system call. + +package flock + +import ( + "os" + "syscall" +) + + +// Lock gets an exclusive lock on the file at the given path. If the file +// doesn't exist, it's created. +func Lock(path string) (*os.File, error) { + perm := os.O_CREATE | os.O_RDWR + fh, err := os.OpenFile(path, perm, 0600) + if err != nil { + return nil, err + } + + if err := syscall.Flock(int(fh.Fd()), syscall.LOCK_EX); err != nil { + return nil, err + } + + return fh, nil +} + +// Unlock releases the lock acquired via the Lock function. +func Unlock(f *os.File) error { + return f.Close() +} diff --git a/flock/flock_test.go b/flock/flock_test.go new file mode 100644 index 0000000..12cac48 --- /dev/null +++ b/flock/flock_test.go @@ -0,0 +1,43 @@ +package flock + +import ( + "testing" + "time" +) + +func Test_Lock_basic(t *testing.T) { + ch := make(chan int, 1) + f, err := Lock("/tmp/fsutil-test-lock") + if err != nil { + t.Fatal(err) + } + go func() { + time.Sleep(time.Second) + ch <- 10 + Unlock(f) + }() + + select { + case x := <-ch: + t.Fatal(x) + default: + + } + + _, _ = Lock("/tmp/fsutil-test-lock") + select { + case i := <-ch: + if i != 10 { + t.Fatal(i) + } + default: + t.Fatal("No value available.") + } +} + +func Test_Lock_badPath(t *testing.T) { + _, err := Lock("./dne/file.lock") + if err == nil { + t.Fatal(err) + } +} diff --git a/fsutil/fsutil.go b/fsutil/fsutil.go new file mode 100644 index 0000000..ad1035e --- /dev/null +++ b/fsutil/fsutil.go @@ -0,0 +1,53 @@ +package fsutil + +import ( + "os" +) + +// Return true if os.Stat doesn't return an error. +func PathExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// Returns true if os.Stat doesn't return an error and the path is a regular file. +func FileExists(path string) bool { + fi, err := os.Stat(path) + if err != nil { + return false + } + return fi.Mode().IsRegular() +} + +// Returns true if os.Stat doesn't return an error and the path is a directory. +func DirExists(path string) bool { + fi, err := os.Stat(path) + if err != nil { + return false + } + return fi.Mode().IsDir() +} + +// Write a file atomically on a POSIX file system. +func WriteFileAtomic(data []byte, tmpPath, finalPath string) error { + f, err := os.Create(tmpPath) + if err != nil { + return err + } + + if _, err := f.Write(data); err != nil { + f.Close() + return err + } + + if err := f.Sync(); err != nil { + f.Close() + return err + } + + if err := f.Close(); err != nil { + return err + } + + return os.Rename(tmpPath, finalPath) +} diff --git a/fsutil/fsutil_test.go b/fsutil/fsutil_test.go new file mode 100644 index 0000000..03ccbbd --- /dev/null +++ b/fsutil/fsutil_test.go @@ -0,0 +1,62 @@ +package fsutil + +import ( + "io/ioutil" + "path/filepath" + "testing" +) + +func TestPathExists(t *testing.T) { + if !PathExists(".") { + t.Fatal(".") + } + if PathExists("./i-dont-exist") { + t.Fatal("2") + } +} + +func TestFileExists(t *testing.T) { + if FileExists(".") { + t.Fatal(".") + } + if FileExists("./i-dont-exist") { + t.Fatal("2") + } + if !FileExists("fsutil.go") { + t.Fatal("fsutil.go") + } +} + +func TestDirExists(t *testing.T) { + if !DirExists(".") { + t.Fatal(".") + } + if DirExists("./i-dont-exist") { + t.Fatal("2") + } + if DirExists("fsutil.go") { + t.Fatal("fsutil.go") + } +} + +func TestWriteFileAtomic(t *testing.T) { + dir, err := ioutil.TempDir("", "123") + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "some-file.dat") + data := []byte("Hello world!") + + if err := WriteFileAtomic(data, path+".new", path); err != nil { + t.Fatal(err) + } + + b, err := ioutil.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + if string(b) != "Hello world!" { + t.Fatal(b) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..37dc892 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.crumpington.com/public/toolbox + +go 1.16 + +require golang.org/x/term v0.0.0-20210317153231-de623e64d2a6 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..02a0d0b --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20210317153231-de623e64d2a6 h1:EC6+IGYTjPpRfv9a2b/6Puw0W+hLtAhkV1tPsXhutqs= +golang.org/x/term v0.0.0-20210317153231-de623e64d2a6/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/tui/escapes.go b/tui/escapes.go new file mode 100644 index 0000000..a6d0536 --- /dev/null +++ b/tui/escapes.go @@ -0,0 +1,10 @@ +package tui + +var ( + EscapeNormal = []byte{27, '[', '0', 'm'} + EscapeBold = []byte{27, '[', '1', 'm'} + EscapeFaint = []byte{27, '[', '2', 'm'} + EscapeItalic = []byte{27, '[', '3', 'm'} + EscapeUnderline = []byte{27, '[', '4', 'm'} + EscapeInvert = []byte{27, '[', '7', 'm'} +) diff --git a/tui/input.go b/tui/input.go new file mode 100644 index 0000000..e15ab65 --- /dev/null +++ b/tui/input.go @@ -0,0 +1,54 @@ +package tui + +import ( + "crypto/rand" + "fmt" + "os" + "os/exec" + "path/filepath" +) + +// Readline reads a line of input from the user. +func Readline(prompt string) string { + t.SetPrompt(prompt) + s, err := t.ReadLine() + must(err) + return s +} + +// ReadPassword reads a line of input from the user, but doesn't echo to the +// screen. +func ReadPassword(prompt string) string { + s, err := t.ReadPassword(prompt) + must(err) + return s +} + +// Edit the string s using the editor defined by the environment variable +// EDITOR. +func EditInEditor(s string) (string, error) { + buf := make([]byte, 16) + rand.Read(buf) + path := filepath.Join(os.TempDir(), fmt.Sprintf("tui.%x", buf)) + defer os.RemoveAll(path) + if err := os.WriteFile(path, []byte(s), 0600); err != nil { + return s, err + } + + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "emacs" + } + + cmd := exec.Command(editor, path) + if err := cmd.Run(); err != nil { + return s, err + } + + buf, err := os.ReadFile(path) + if err != nil { + return s, err + } + + return string(buf), nil +} diff --git a/tui/output.go b/tui/output.go new file mode 100644 index 0000000..83c37c6 --- /dev/null +++ b/tui/output.go @@ -0,0 +1,35 @@ +package tui + +import ( + "fmt" +) + +// Clear clears the terminal. +func Clear() { + _, h := TermSize() + buf := make([]byte, 2*h) + for i := range buf { + buf[i] = '\n' + } + mustWrite(buf) +} + +// Write sends raw bytes to the terminal. +func Write(b []byte) { + mustWrite(b) +} + +func Print(a ...interface{}) { + s := fmt.Sprint(a...) + mustWrite([]byte(s)) +} + +func Println(a ...interface{}) { + s := fmt.Sprintln(a...) + mustWrite([]byte(s)) +} + +func Printf(format string, a ...interface{}) { + s := fmt.Sprintf(format, a...) + mustWrite([]byte(s)) +} diff --git a/tui/stringutil.go b/tui/stringutil.go new file mode 100644 index 0000000..c76a750 --- /dev/null +++ b/tui/stringutil.go @@ -0,0 +1,68 @@ +package tui + +import ( + "strings" + "unicode" +) + +// Split text into words or newlines. +func splitText(s string) [][]rune { + s = strings.TrimSpace(s) + r := []rune(s) + out := [][]rune{} + + nextWord := func() []rune { + for i := range r { + if unicode.IsSpace(r[i]) { + ret := r[:i] + r = r[i:] + return ret + } + } + ret := r + r = r[:0] + return ret + } + + nextSpaces := func() []rune { + for i := range r { + if !unicode.IsSpace(r[i]) { + ret := r[:i] + r = r[i:] + return ret + } + } + // Code should never reach these lines. + ret := r + r = r[:0] + return ret + } + + for len(r) > 0 { + word := nextWord() + if len(word) != 0 { + out = append(out, word) + } + + if len(r) == 0 { + break + } + + spaces := nextSpaces() + count := 0 + for _, x := range spaces { + if x == '\n' { + count++ + } + if count > 1 { + break + } + } + + if count > 1 { + out = append(out, []rune{'\n'}) + } + } + + return out +} diff --git a/tui/tui.go b/tui/tui.go new file mode 100644 index 0000000..935908d --- /dev/null +++ b/tui/tui.go @@ -0,0 +1,49 @@ +package tui + +import ( + "io" + "os" + "sync" + + "golang.org/x/term" +) + +var ( + lock = sync.Mutex{} + t *term.Terminal + oldState *term.State +) + +func Start() { + lock.Lock() + defer lock.Unlock() + if t != nil { + return + } + + screen := struct { + io.Reader + io.Writer + }{os.Stdin, os.Stdout} + + var err error + oldState, err = term.MakeRaw(int(os.Stdin.Fd())) + if err != nil { + panic(err) + } + + t = term.NewTerminal(screen, "") + t.SetSize(80, 24) +} + +func Stop() { + lock.Lock() + defer lock.Unlock() + if t == nil { + return + } + + term.Restore(int(os.Stdin.Fd()), oldState) + t = nil + oldState = nil +} diff --git a/tui/util.go b/tui/util.go new file mode 100644 index 0000000..a50d050 --- /dev/null +++ b/tui/util.go @@ -0,0 +1,23 @@ +package tui + +import ( + "os" + + "golang.org/x/term" +) + +func must(err error) { + if err != nil { + panic(err) + } +} + +func TermSize() (w, h int) { + w, h, _ = term.GetSize(int(os.Stdin.Fd())) + return w, h +} + +func mustWrite(buf []byte) { + _, err := t.Write(buf) + must(err) +} diff --git a/tui/wordwrap.go b/tui/wordwrap.go new file mode 100644 index 0000000..766d335 --- /dev/null +++ b/tui/wordwrap.go @@ -0,0 +1,60 @@ +package tui + +import "strings" + +// WrapText takes the text in s and wraps it to the width. It returns the +// individual wrapped lines, and the length of the longest wrapped line. +// +// If the width is less than or equal to zero, it is set to the terminal width. +// +// In order to wrap the text in a reasonable way, white space is trimmed and +// collapsed througout the text. +func WrapText(s string, width int) ([]string, int) { + w := width + if w <= 0 { + w, _ = TermSize() + } + + maxLine := 0 + parts := splitText(s) + + nextLine := func() (string, int) { + if parts[0][0] == '\n' { + parts = parts[1:] + return "", 0 + } + + words := append([]string{}, string(parts[0])) + length := len(parts[0]) + parts = parts[1:] + + for len(parts) > 0 { + p := parts[0] + + if p[0] == '\n' { + break + } + + if length+len(p)+1 > w { + break + } + + length += len(p) + 1 + words = append(words, string(p)) + parts = parts[1:] + } + + return strings.Join(words, " "), length + } + + lines := make([]string, 0, 2) + for len(parts) > 0 { + line, lineLen := nextLine() + if lineLen > maxLine { + maxLine = lineLen + } + lines = append(lines, line) + } + + return lines, maxLine +} diff --git a/tui/wordwrap_test.go b/tui/wordwrap_test.go new file mode 100644 index 0000000..d5f2f4b --- /dev/null +++ b/tui/wordwrap_test.go @@ -0,0 +1,56 @@ +package tui + +import ( + "reflect" + "testing" +) + +func TestWrapText(t *testing.T) { + type TestCase struct { + In string + W int + Lines []string + Width int + } + + cases := []TestCase{ + { + In: "The rain in spain falls mainly in the plane.", + W: 9, + Lines: []string{ + "The rain", + "in spain", + "falls", + "mainly in", + "the", + "plane.", + }, + Width: 9, + }, { + In: "The\n\nrain in spain falls mainly in the plane.", + W: 9, + Lines: []string{ + "The", + "", + "rain in", + "spain", + "falls", + "mainly in", + "the", + "plane.", + }, + Width: 9, + }, + } + + for _, tc := range cases { + lines, width := WrapText(tc.In, tc.W) + if !reflect.DeepEqual(lines, tc.Lines) { + t.Fatalf("%#v %#v", lines, tc.Lines) + } + + if width != tc.Width { + t.Fatal(width, tc.Width) + } + } +}