Initial Go project structure, docs, build and CI
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
// Package textutil holds display helpers: sanitising untrusted strings for
|
||||
// the terminal, width-aware truncation, and human-readable units.
|
||||
package textutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/rivo/uniseg"
|
||||
)
|
||||
|
||||
// Sanitize makes an untrusted string (a filename, snapshot data) safe to
|
||||
// write to a terminal. Control characters (C0, DEL, C1) and bidi overrides are
|
||||
// replaced by visible escapes; invalid UTF-8 becomes U+FFFD. The original
|
||||
// string should be kept for filesystem operations.
|
||||
func Sanitize(s string) string {
|
||||
clean := true
|
||||
for i := 0; i < len(s); i++ {
|
||||
if c := s[i]; c < 0x20 || c >= 0x7f {
|
||||
clean = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if clean {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(s) + 8)
|
||||
for i := 0; i < len(s); {
|
||||
r, size := utf8.DecodeRuneInString(s[i:])
|
||||
if r == utf8.RuneError && size <= 1 {
|
||||
b.WriteRune(utf8.RuneError)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
i += size
|
||||
switch {
|
||||
case r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f):
|
||||
fmt.Fprintf(&b, `\x%02x`, r)
|
||||
case isBidiControl(r):
|
||||
fmt.Fprintf(&b, `\u%04x`, r)
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func isBidiControl(r rune) bool {
|
||||
return (r >= 0x202a && r <= 0x202e) || (r >= 0x2066 && r <= 0x2069) ||
|
||||
r == 0x200e || r == 0x200f || r == 0x061c
|
||||
}
|
||||
|
||||
// Width returns the display width of s in terminal cells.
|
||||
func Width(s string) int { return uniseg.StringWidth(s) }
|
||||
|
||||
// Truncate clips s to at most w cells, appending "…" if anything was cut.
|
||||
func Truncate(s string, w int) string {
|
||||
if w <= 0 {
|
||||
return ""
|
||||
}
|
||||
if Width(s) <= w {
|
||||
return s
|
||||
}
|
||||
return clip(s, w-1) + "…"
|
||||
}
|
||||
|
||||
// TruncateLeft clips from the left, prefixing "…" (used for paths and
|
||||
// breadcrumbs where the tail is the informative part).
|
||||
func TruncateLeft(s string, w int) string {
|
||||
if w <= 0 {
|
||||
return ""
|
||||
}
|
||||
total := Width(s)
|
||||
if total <= w {
|
||||
return s
|
||||
}
|
||||
skip := total - (w - 1)
|
||||
state := -1
|
||||
rest := s
|
||||
for rest != "" && skip > 0 {
|
||||
var cw int
|
||||
_, rest, cw, state = uniseg.FirstGraphemeClusterInString(rest, state)
|
||||
skip -= cw
|
||||
}
|
||||
// A wide cluster may have overshot by one cell; pad to keep width exact.
|
||||
return "…" + strings.Repeat(" ", -skip) + rest
|
||||
}
|
||||
|
||||
// clip returns the longest prefix of s that fits in w cells.
|
||||
func clip(s string, w int) string {
|
||||
state := -1
|
||||
used, end := 0, 0
|
||||
rest := s
|
||||
for rest != "" {
|
||||
var cluster string
|
||||
var cw int
|
||||
cluster, rest, cw, state = uniseg.FirstGraphemeClusterInString(rest, state)
|
||||
if used+cw > w {
|
||||
break
|
||||
}
|
||||
used += cw
|
||||
end += len(cluster)
|
||||
}
|
||||
return s[:end]
|
||||
}
|
||||
|
||||
// PadRight pads s with spaces to exactly w cells (truncating if longer).
|
||||
func PadRight(s string, w int) string {
|
||||
s = Truncate(s, w)
|
||||
if d := w - Width(s); d > 0 {
|
||||
s += strings.Repeat(" ", d)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// PadLeft right-aligns s in w cells.
|
||||
func PadLeft(s string, w int) string {
|
||||
s = Truncate(s, w)
|
||||
if d := w - Width(s); d > 0 {
|
||||
s = strings.Repeat(" ", d) + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package textutil
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitize(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"plain.txt": "plain.txt",
|
||||
"evil\x1b[2Jname": `evil\x1b[2Jname`,
|
||||
"bell\a": `bell\x07`,
|
||||
"c1\u009bx": `c1\x9bx`,
|
||||
"bad\xffutf8": "bad�utf8",
|
||||
"rtl\u202egnp.exe": `rtl\u202egnp.exe`,
|
||||
"日本語": "日本語",
|
||||
"tab\tnew\nline\rdel\x7f": `tab\x09new\x0aline\x0ddel\x7f`,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := Sanitize(in); got != want {
|
||||
t.Errorf("Sanitize(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
if strings.ContainsAny(Sanitize(in), "\x1b\a\u009b") {
|
||||
t.Errorf("control byte survived in %q", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
if got := Truncate("hello world", 5); got != "hell…" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
if got := Truncate("日本語テキスト", 5); Width(got) > 5 {
|
||||
t.Errorf("wide truncation overflow: %q width %d", got, Width(got))
|
||||
}
|
||||
if got := Truncate("abc", 3); got != "abc" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
if got := Truncate("abc", 0); got != "" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
if got := TruncateLeft("/a/very/long/path", 8); Width(got) != 8 || !strings.HasSuffix(got, "path") {
|
||||
t.Errorf("TruncateLeft got %q", got)
|
||||
}
|
||||
if got := TruncateLeft("ab日本語", 4); Width(got) != 4 {
|
||||
t.Errorf("TruncateLeft wide got %q (%d)", got, Width(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSize(t *testing.T) {
|
||||
cases := map[int64]string{0: "0 B", 1023: "1023 B", 1024: "1.00 KiB", 1536: "1.50 KiB",
|
||||
181 << 30: "181 GiB", 1 << 62: "4.00 EiB"}
|
||||
for in, want := range cases {
|
||||
if got := Size(in); got != want {
|
||||
t.Errorf("Size(%d)=%q want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
if got := Count(2184392); got != "2,184,392" {
|
||||
t.Errorf("Count got %q", got)
|
||||
}
|
||||
if got := SignedSize(-2048); got != "-2.00 KiB" {
|
||||
t.Errorf("SignedSize got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package textutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SI selects decimal units (kB, MB, …). The default is IEC (KiB, MiB, …).
|
||||
// It is set once at start-up, before any goroutines format sizes.
|
||||
var SI bool
|
||||
|
||||
var (
|
||||
iecUnits = []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"}
|
||||
siUnits = []string{"B", "kB", "MB", "GB", "TB", "PB", "EB"}
|
||||
)
|
||||
|
||||
func scaled(n int64) (float64, string) {
|
||||
base, units := 1024.0, iecUnits
|
||||
if SI {
|
||||
base, units = 1000.0, siUnits
|
||||
}
|
||||
neg := n < 0
|
||||
v := float64(n)
|
||||
if neg {
|
||||
v = -v
|
||||
}
|
||||
i := 0
|
||||
for v >= base && i < len(units)-1 {
|
||||
v /= base
|
||||
i++
|
||||
}
|
||||
if neg {
|
||||
v = -v
|
||||
}
|
||||
return v, units[i]
|
||||
}
|
||||
|
||||
func num(v float64, unit string) string {
|
||||
a := v
|
||||
if a < 0 {
|
||||
a = -a
|
||||
}
|
||||
switch {
|
||||
case unit == "B":
|
||||
return strconv.FormatFloat(v, 'f', 0, 64)
|
||||
case a < 10:
|
||||
return strconv.FormatFloat(v, 'f', 2, 64)
|
||||
case a < 100:
|
||||
return strconv.FormatFloat(v, 'f', 1, 64)
|
||||
default:
|
||||
return strconv.FormatFloat(v, 'f', 0, 64)
|
||||
}
|
||||
}
|
||||
|
||||
// Size formats a byte count, e.g. "86.4 GiB".
|
||||
func Size(n int64) string {
|
||||
v, u := scaled(n)
|
||||
return num(v, u) + " " + u
|
||||
}
|
||||
|
||||
// SizeCompact formats a byte count in at most ~7 cells, e.g. "86GiB".
|
||||
func SizeCompact(n int64) string {
|
||||
v, u := scaled(n)
|
||||
a := v
|
||||
if a < 0 {
|
||||
a = -a
|
||||
}
|
||||
if u != "B" && a < 10 {
|
||||
return strconv.FormatFloat(v, 'f', 1, 64) + u
|
||||
}
|
||||
return strconv.FormatFloat(v, 'f', 0, 64) + u
|
||||
}
|
||||
|
||||
// SignedSize formats a delta with an explicit sign.
|
||||
func SignedSize(n int64) string {
|
||||
if n > 0 {
|
||||
return "+" + Size(n)
|
||||
}
|
||||
if n < 0 {
|
||||
return "-" + Size(-n)
|
||||
}
|
||||
return "±0 B"
|
||||
}
|
||||
|
||||
// Count formats an integer with thousands separators.
|
||||
func Count(n int64) string {
|
||||
s := strconv.FormatInt(n, 10)
|
||||
neg := false
|
||||
if n < 0 {
|
||||
neg, s = true, s[1:]
|
||||
}
|
||||
out := make([]byte, 0, len(s)+len(s)/3)
|
||||
for i := range len(s) {
|
||||
if i > 0 && (len(s)-i)%3 == 0 {
|
||||
out = append(out, ',')
|
||||
}
|
||||
out = append(out, s[i])
|
||||
}
|
||||
if neg {
|
||||
return "-" + string(out)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// Duration formats an elapsed time as mm:ss or h:mm:ss.
|
||||
func Duration(d time.Duration) string {
|
||||
s := int64(d.Seconds())
|
||||
if s >= 3600 {
|
||||
return fmt.Sprintf("%d:%02d:%02d", s/3600, s/60%60, s%60)
|
||||
}
|
||||
return fmt.Sprintf("%02d:%02d", s/60, s%60)
|
||||
}
|
||||
|
||||
// Percent formats part/whole as a percentage.
|
||||
func Percent(part, whole int64) string {
|
||||
if whole <= 0 {
|
||||
return "–"
|
||||
}
|
||||
p := float64(part) * 100 / float64(whole)
|
||||
if p < 10 {
|
||||
return strconv.FormatFloat(p, 'f', 1, 64) + "%"
|
||||
}
|
||||
return strconv.FormatFloat(p, 'f', 0, 64) + "%"
|
||||
}
|
||||
Reference in New Issue
Block a user