sort-cli: rework

This commit is contained in:
2024-01-29 14:06:41 +03:00
parent a17d379bd1
commit 380099b9b3
6 changed files with 253 additions and 192 deletions

50
sort-cli/Makefile Normal file
View File

@ -0,0 +1,50 @@
.DEFAULT_GOAL := help
# ==================================================================================== #
# HELPERS
# ==================================================================================== #
## help: print this help message
.PHONY: help
help:
@echo 'Usage:'
@sed -n 's/^##//p' ${MAKEFILE_LIST} | column -t -s ':' | sed -e 's/^/ /'
## all: tidy + audit + test/cover
all: tidy audit test/cover
# ==================================================================================== #
# QUALITY CONTROL
# ==================================================================================== #
## tidy: format code and tidy modfile
.PHONY: tidy
tidy:
go fmt ./...
go mod tidy -v
## audit: run quality control checks
.PHONY: audit
audit:
go mod verify
go vet ./...
go run honnef.co/go/tools/cmd/staticcheck@latest -checks=all,-ST1000,-U1000 ./...
go run golang.org/x/vuln/cmd/govulncheck@latest ./...
go test -race -buildvcs -vet=off ./...
go run github.com/golangci/golangci-lint/cmd/golangci-lint@latest run -v ./...
# ==================================================================================== #
# DEVELOPMENT
# ==================================================================================== #
## test: run all tests
.PHONY: test
test:
go test -v -race -buildvcs ./...
## test/cover: run all tests and display coverage
.PHONY: test/cover
test/cover:
go test -v -race -buildvcs -coverprofile=/tmp/coverage.out ./...
go tool cover -html=/tmp/coverage.out

26
sort-cli/config.go Normal file
View File

@ -0,0 +1,26 @@
package main
import "flag"
type Config struct {
Key int
Numeric bool
Reverse bool
Unique bool
Sources []string
}
func (c *Config) ParseFlags() {
flag.IntVar(&c.Key, "k", 0, "sort via column")
flag.BoolVar(&c.Numeric, "n", false, "compare according to string numerical value")
flag.BoolVar(&c.Reverse, "r", false, "reverse the result of comparisons")
flag.BoolVar(&c.Unique, "u", false, "output only the first of an equal run")
flag.Parse()
c.Sources = flag.Args()
if len(c.Sources) == 0 {
c.Sources = []string{"-"}
}
}

80
sort-cli/content.go Normal file
View File

@ -0,0 +1,80 @@
package main
import (
"bufio"
"errors"
"golang.org/x/exp/slices"
"io"
"log"
"strings"
)
type content [][]byte
func (c *content) Sort(reverse bool) {
slices.SortFunc(*c, func(a, b []byte) int {
if reverse {
a, b = b, a
}
return slices.Compare(a, b)
})
}
func (c *content) Uniques() {
m := make(map[string]struct{}, len(*c))
*c = slices.DeleteFunc[content, []byte](*c, func(line []byte) bool {
if _, ok := m[string(line)]; !ok {
m[string(line)] = struct{}{}
return false
}
return true
})
}
func (c *content) String() string {
lines := *c
var n int
for _, line := range lines {
n += len(line)
}
n += len(lines) * len("\n")
var sb strings.Builder
sb.Grow(n)
for i, line := range lines {
if i > 0 {
sb.WriteString("\n")
}
for _, rn := range line {
sb.WriteByte(rn)
}
}
return sb.String()
}
func (c *content) Load(r io.Reader) {
br := bufio.NewReader(r)
for {
line, _, err := br.ReadLine()
if err != nil {
if errors.Is(io.EOF, err) {
*c = append(*c, line)
break
}
log.Fatalf("can't read line: %s", err)
}
*c = append(*c, line)
}
}

51
sort-cli/content_test.go Normal file
View File

@ -0,0 +1,51 @@
package main
import (
"github.com/stretchr/testify/assert"
"strings"
"testing"
)
const input = `
Zimbabwe
Africa
America
Zimbabwe
Africa
`
func TestLoad(t *testing.T) {
c := &content{}
c.Load(strings.NewReader(input))
assert.Equal(t, input, c.String())
}
func TestUnique(t *testing.T) {
cases := []struct {
Name string
Content string
Expected string
}{
{"Unique",
input,
`
Zimbabwe
Africa
America`,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.Name, func(t *testing.T) {
c := &content{}
c.Load(strings.NewReader(tc.Content))
c.Uniques()
assert.Equal(t, tc.Expected, c.String())
})
}
}

34
sort-cli/loader.go Normal file
View File

@ -0,0 +1,34 @@
package main
import (
"io"
"log"
"os"
)
func open(sources []string) io.Reader {
rs := make([]io.Reader, 0, len(sources))
for _, source := range sources {
var r io.Reader
if source == "-" {
r = os.Stdin
} else {
if _, err := os.Stat(source); err != nil {
log.Fatalf("file not exists: %s", source)
}
f, err := os.Open(source)
if err != nil {
log.Fatalf("file open file: %s", err)
}
r = f
}
rs = append(rs, r)
}
return io.MultiReader(rs...)
}

View File

@ -1,211 +1,31 @@
package main package main
import ( import (
"bytes"
"flag"
"fmt"
"golang.org/x/exp/maps"
"io"
"log"
"os" "os"
"sort"
"strings"
) )
type Cfg struct {
Key int
Numeric bool
Reverse bool
Unique bool
}
func main() { func main() {
fmt.Print(run()) result := run()
if _, err := os.Stdout.WriteString(result); err != nil {
panic(err)
}
os.Exit(0) os.Exit(0)
} }
func run() string { func run() string {
var c Cfg cfg := &Config{}
cfg.ParseFlags()
flag.IntVar(&c.Key, "k", 0, "sort via column") lines := content{}
flag.BoolVar(&c.Numeric, "n", false, "compare according to string numerical value") lines.Load(open(cfg.Sources))
flag.BoolVar(&c.Reverse, "r", false, "reverse the result of comparisons")
flag.BoolVar(&c.Unique, "u", false, "output only the first of an equal run")
flag.Parse()
return doSort(&c, flag.Args())
}
func doSort(cfg *Cfg, sources []string) string {
r := splitLines(load(sources), []byte("\n"), cfg.Key)
if cfg.Unique { if cfg.Unique {
r = uniques(r) lines.Uniques()
} }
rs := &Rows{s: r, cfg: cfg} lines.Sort(cfg.Reverse)
sort.Sort(rs)
return rs.String() return lines.String()
}
type Row struct {
Line []byte
Column []byte
}
type Rows struct {
s []*Row
cfg *Cfg
}
func (p *Rows) Len() int { return len(p.s) }
func (p *Rows) Less(i, j int) bool {
s := p.s
lr := s[i]
rr := s[j]
if p.cfg.Reverse {
lr, rr = rr, lr
}
var l, r []rune
if p.cfg.Key == 0 {
l, r = bytes.Runes(lr.Line), bytes.Runes(rr.Line)
} else {
l, r = bytes.Runes(lr.Column), bytes.Runes(rr.Column)
}
ln := len(l)
rn := len(r)
for i := 0; i < min(ln, rn); i++ {
if l[i] == r[i] {
continue
}
return l[i] < r[i]
}
return ln < rn
}
func (p *Rows) Swap(i, j int) {
s := p.s
s[i], s[j] = s[j], s[i]
}
func (p *Rows) String() string {
r := p.s
var n int
for _, s := range r {
n += len(s.Line)
}
n += len(r) * len("\n")
var sb strings.Builder
sb.Grow(n)
for _, c := range r {
for _, rn := range c.Line {
sb.WriteByte(rn)
}
sb.WriteString("\n")
}
return sb.String()
}
func load(sources []string) []byte {
if len(sources) == 0 {
return loadStdin()
}
inputs := make([][]byte, 0, len(sources))
for _, path := range sources {
if path == "-" {
inputs = append(inputs, loadStdin())
continue
}
inputs = append(inputs, loadFile(path))
}
var totalSize int
for _, s := range inputs {
totalSize += len(s)
}
var b bytes.Buffer
b.Grow(totalSize)
for _, c := range inputs {
b.Write(c)
}
return b.Bytes()
}
func loadStdin() []byte {
b, err := io.ReadAll(os.Stdin)
if err != nil {
log.Fatalf("can't read stdin: %e", err)
}
return b
}
func loadFile(path string) []byte {
if _, err := os.Stat(path); err != nil {
log.Fatalf("file not exists: %s", path)
}
content, err := os.ReadFile(path)
if err != nil {
log.Fatalf("file open file: %s", err)
}
return content
}
func splitLines(b, sp []byte, key int) []*Row {
r := make([]*Row, 0, bytes.Count(b, sp))
for _, b := range bytes.Split(b, sp) {
var column []byte
if key != 0 {
bs := bytes.Split(b, []byte(" "))
if len(bs) < key {
continue // TODO is it error or not?
// log.Fatalf("Column for key \"%d\" doesn't exists", cfg.Key)
}
column = bs[key-1]
}
r = append(r, &Row{Line: b, Column: column})
}
return r
}
func uniques(r []*Row) []*Row {
m := make(map[string]*Row, len(r))
for _, r := range r {
r := r
m[string(r.Line)] = r
}
return maps.Values(m)
} }