diff --git a/sort-cli/Makefile b/sort-cli/Makefile new file mode 100644 index 0000000..19ff9ab --- /dev/null +++ b/sort-cli/Makefile @@ -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 diff --git a/sort-cli/config.go b/sort-cli/config.go new file mode 100644 index 0000000..5edf70d --- /dev/null +++ b/sort-cli/config.go @@ -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{"-"} + } +} diff --git a/sort-cli/content.go b/sort-cli/content.go new file mode 100644 index 0000000..5222972 --- /dev/null +++ b/sort-cli/content.go @@ -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) + } +} diff --git a/sort-cli/content_test.go b/sort-cli/content_test.go new file mode 100644 index 0000000..3858296 --- /dev/null +++ b/sort-cli/content_test.go @@ -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()) + }) + } +} diff --git a/sort-cli/loader.go b/sort-cli/loader.go new file mode 100644 index 0000000..fb9857a --- /dev/null +++ b/sort-cli/loader.go @@ -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...) +} diff --git a/sort-cli/main.go b/sort-cli/main.go index 0bf900d..62b9d53 100644 --- a/sort-cli/main.go +++ b/sort-cli/main.go @@ -1,211 +1,31 @@ package main import ( - "bytes" - "flag" - "fmt" - "golang.org/x/exp/maps" - "io" - "log" "os" - "sort" - "strings" ) -type Cfg struct { - Key int - Numeric bool - Reverse bool - Unique bool -} - func main() { - fmt.Print(run()) + result := run() + + if _, err := os.Stdout.WriteString(result); err != nil { + panic(err) + } os.Exit(0) } func run() string { - var c Cfg + cfg := &Config{} + cfg.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() - - return doSort(&c, flag.Args()) -} - -func doSort(cfg *Cfg, sources []string) string { - r := splitLines(load(sources), []byte("\n"), cfg.Key) + lines := content{} + lines.Load(open(cfg.Sources)) if cfg.Unique { - r = uniques(r) + lines.Uniques() } - rs := &Rows{s: r, cfg: cfg} - sort.Sort(rs) + lines.Sort(cfg.Reverse) - return rs.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) + return lines.String() }