From 524885de39a86ace117698e2d4a9ce7d366381cf Mon Sep 17 00:00:00 2001 From: webbrain-one <295484252+webbrain-one@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:00:17 +0300 Subject: [PATCH 1/6] feat: add -sort.asc and -sort.dsc flags for output sorting Allows sorting output in ascending or descending order. Useful in combination with `-format table`. Fixes #5 --- cmd/csvq/main.go | 5 +++++ internal/cli/file.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/cmd/csvq/main.go b/cmd/csvq/main.go index 45841b6..80e0163 100644 --- a/cmd/csvq/main.go +++ b/cmd/csvq/main.go @@ -36,6 +36,9 @@ var ( flagKeepCols = flag.String("keep", "", "Column headers to keep in output. Order of kept headers is maintained in output.") + flagSortAsc = flag.String("sort.asc", "", "Comma-separated column headers to sort output by (ascending)") + flagSortDesc = flag.String("sort.dsc", "", "Comma-separated column headers to sort output by (descending)") + flagFormat = flag.String("format", "", "Format to output resulting records in") flagVerbose = flag.Bool("v", false, "Enable verbose logging") @@ -72,6 +75,8 @@ func main() { Delimiter: toRune(*flagDelimiter), ShowHeaders: *flagShowHeaders, KeepCols: splitStringList(*flagKeepCols), + SortAsc: splitStringList(*flagSortAsc), + SortDesc: splitStringList(*flagSortDesc), } for i := range files { diff --git a/internal/cli/file.go b/internal/cli/file.go index 546de47..f4101c0 100644 --- a/internal/cli/file.go +++ b/internal/cli/file.go @@ -22,6 +22,7 @@ import ( "errors" "fmt" "io" + "sort" "strings" ) @@ -30,6 +31,8 @@ type FileOpts struct { ShowHeaders bool KeepCols []string + SortAsc []string + SortDesc []string } type File struct { @@ -103,5 +106,37 @@ func HandleFile(opts FileOpts, r io.Reader) (*File, error) { output.Lines = append(output.Lines, line) } + if len(opts.SortAsc) > 0 || len(opts.SortDesc) > 0 { + ascIdxs := resolveColIndices(output.Headers, opts.SortAsc) + dscIdxs := resolveColIndices(output.Headers, opts.SortDesc) + + sort.SliceStable(output.Lines, func(i, j int) bool { + for _, idx := range ascIdxs { + if output.Lines[i][idx] != output.Lines[j][idx] { + return output.Lines[i][idx] < output.Lines[j][idx] + } + } + for _, idx := range dscIdxs { + if output.Lines[i][idx] != output.Lines[j][idx] { + return output.Lines[i][idx] > output.Lines[j][idx] + } + } + return false + }) + } return &output, nil } + +func resolveColIndices(headers []string, names []string) []int { + var idxs []int + for _, name := range names { + name = strings.TrimSpace(name) + for i, h := range headers { + if strings.EqualFold(strings.TrimSpace(h), name) { + idxs = append(idxs, i) + break + } + } + } + return idxs +} From 4a77c4d38e3659eee3b3ff4bcad0c8d1c93c8cb1 Mon Sep 17 00:00:00 2001 From: Adam Shannon Date: Wed, 12 Aug 2026 21:56:30 -0500 Subject: [PATCH 2/6] fix: resolve sort keys in keep order and honor flag order Sort against the -keep index space so reordered columns sort the requested field. Unknown sort names are errors. Mixed -sort.asc and -sort.dsc flags apply in invocation order, and numeric-looking cells compare as numbers. --- cmd/csvq/main.go | 45 +++++++++++++++++++-- internal/cli/file.go | 94 +++++++++++++++++++++++++++++++++----------- 2 files changed, 112 insertions(+), 27 deletions(-) diff --git a/cmd/csvq/main.go b/cmd/csvq/main.go index 80e0163..26b2051 100644 --- a/cmd/csvq/main.go +++ b/cmd/csvq/main.go @@ -36,8 +36,8 @@ var ( flagKeepCols = flag.String("keep", "", "Column headers to keep in output. Order of kept headers is maintained in output.") - flagSortAsc = flag.String("sort.asc", "", "Comma-separated column headers to sort output by (ascending)") - flagSortDesc = flag.String("sort.dsc", "", "Comma-separated column headers to sort output by (descending)") + _ = flag.String("sort.asc", "", "Comma-separated column headers to sort output by (ascending)") + _ = flag.String("sort.dsc", "", "Comma-separated column headers to sort output by (descending)") flagFormat = flag.String("format", "", "Format to output resulting records in") @@ -75,8 +75,7 @@ func main() { Delimiter: toRune(*flagDelimiter), ShowHeaders: *flagShowHeaders, KeepCols: splitStringList(*flagKeepCols), - SortAsc: splitStringList(*flagSortAsc), - SortDesc: splitStringList(*flagSortDesc), + SortKeys: parseSortKeys(os.Args[1:]), } for i := range files { @@ -116,3 +115,41 @@ func splitStringList(input string) []string { } return ss } + +// parseSortKeys walks argv so mixed -sort.asc / -sort.dsc flags keep invocation order. +func parseSortKeys(args []string) []cli.SortKey { + var keys []cli.SortKey + for i := 0; i < len(args); i++ { + arg := args[i] + val, desc, ok, skipNext := sortFlagValue(arg, args, i) + if !ok { + continue + } + if skipNext { + i++ + } + for _, name := range splitStringList(val) { + keys = append(keys, cli.SortKey{Name: name, Desc: desc}) + } + } + return keys +} + +func sortFlagValue(arg string, args []string, i int) (val string, desc bool, ok bool, skipNext bool) { + name, inline, hasInline := strings.Cut(arg, "=") + switch name { + case "-sort.asc", "--sort.asc": + desc = false + case "-sort.dsc", "--sort.dsc": + desc = true + default: + return "", false, false, false + } + if hasInline { + return inline, desc, true, false + } + if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { + return args[i+1], desc, true, true + } + return "", desc, true, false +} diff --git a/internal/cli/file.go b/internal/cli/file.go index f4101c0..e7f54c9 100644 --- a/internal/cli/file.go +++ b/internal/cli/file.go @@ -23,16 +23,22 @@ import ( "fmt" "io" "sort" + "strconv" "strings" ) +// SortKey is one output sort column. Desc is true for -sort.dsc. +type SortKey struct { + Name string + Desc bool +} + type FileOpts struct { Delimiter rune ShowHeaders bool KeepCols []string - SortAsc []string - SortDesc []string + SortKeys []SortKey } type File struct { @@ -106,20 +112,22 @@ func HandleFile(opts FileOpts, r io.Reader) (*File, error) { output.Lines = append(output.Lines, line) } - if len(opts.SortAsc) > 0 || len(opts.SortDesc) > 0 { - ascIdxs := resolveColIndices(output.Headers, opts.SortAsc) - dscIdxs := resolveColIndices(output.Headers, opts.SortDesc) - + if len(opts.SortKeys) > 0 { + // Row values are stored in -keep order, which can differ from output.Headers. + resolved, err := resolveSortKeys(toKeep, opts.SortKeys) + if err != nil { + return nil, err + } sort.SliceStable(output.Lines, func(i, j int) bool { - for _, idx := range ascIdxs { - if output.Lines[i][idx] != output.Lines[j][idx] { - return output.Lines[i][idx] < output.Lines[j][idx] + for _, key := range resolved { + cmp := compareCell(output.Lines[i][key.idx], output.Lines[j][key.idx]) + if cmp == 0 { + continue } - } - for _, idx := range dscIdxs { - if output.Lines[i][idx] != output.Lines[j][idx] { - return output.Lines[i][idx] > output.Lines[j][idx] + if key.desc { + return cmp > 0 } + return cmp < 0 } return false }) @@ -127,16 +135,56 @@ func HandleFile(opts FileOpts, r io.Reader) (*File, error) { return &output, nil } -func resolveColIndices(headers []string, names []string) []int { - var idxs []int - for _, name := range names { - name = strings.TrimSpace(name) - for i, h := range headers { - if strings.EqualFold(strings.TrimSpace(h), name) { - idxs = append(idxs, i) - break - } +type resolvedSortKey struct { + idx int + desc bool +} + +func resolveSortKeys(cols []string, keys []SortKey) ([]resolvedSortKey, error) { + var out []resolvedSortKey + for _, key := range keys { + name := strings.TrimSpace(key.Name) + if name == "" { + continue } + idx := indexCol(cols, name) + if idx < 0 { + return nil, fmt.Errorf("unknown sort column %q", key.Name) + } + out = append(out, resolvedSortKey{idx: idx, desc: key.Desc}) + } + return out, nil +} + +func indexCol(cols []string, name string) int { + for i, col := range cols { + if strings.EqualFold(strings.TrimSpace(col), name) { + return i + } + } + return -1 +} + +// compareCell orders numeric-looking cells as numbers, otherwise as strings. +func compareCell(a, b string) int { + an, aErr := strconv.ParseFloat(strings.TrimSpace(a), 64) + bn, bErr := strconv.ParseFloat(strings.TrimSpace(b), 64) + if aErr == nil && bErr == nil { + switch { + case an < bn: + return -1 + case an > bn: + return 1 + default: + return 0 + } + } + switch { + case a < b: + return -1 + case a > b: + return 1 + default: + return 0 } - return idxs } From adaab09cea7d9bd635d5263d13c9644d902114ac Mon Sep 17 00:00:00 2001 From: Adam Shannon Date: Wed, 12 Aug 2026 21:56:30 -0500 Subject: [PATCH 3/6] test: cover HandleFile sorting and sort flag parsing --- cmd/csvq/main_test.go | 50 +++++++++ internal/cli/file_test.go | 221 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 internal/cli/file_test.go diff --git a/cmd/csvq/main_test.go b/cmd/csvq/main_test.go index 8c7fda6..36512ef 100644 --- a/cmd/csvq/main_test.go +++ b/cmd/csvq/main_test.go @@ -3,6 +3,8 @@ package main import ( "testing" + "github.com/adamdecaf/csvq/internal/cli" + "github.com/stretchr/testify/require" ) @@ -10,3 +12,51 @@ func TestSplitStringList(t *testing.T) { got := splitStringList("") require.Empty(t, got) } + +func TestParseSortKeys(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + want []cli.SortKey + }{ + { + name: "empty", + args: []string{"file.csv"}, + want: nil, + }, + { + name: "desc then asc keeps invocation order", + args: []string{"-sort.dsc", "score", "-sort.asc", "name", "file.csv"}, + want: []cli.SortKey{ + {Name: "score", Desc: true}, + {Name: "name"}, + }, + }, + { + name: "equals form and comma list", + args: []string{"-sort.asc=name,date", "-sort.dsc=score"}, + want: []cli.SortKey{ + {Name: "name"}, + {Name: "date"}, + {Name: "score", Desc: true}, + }, + }, + { + name: "double dash", + args: []string{"--sort.dsc", "score", "--sort.asc=name"}, + want: []cli.SortKey{ + {Name: "score", Desc: true}, + {Name: "name"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, parseSortKeys(tt.args)) + }) + } +} diff --git a/internal/cli/file_test.go b/internal/cli/file_test.go new file mode 100644 index 0000000..22ec046 --- /dev/null +++ b/internal/cli/file_test.go @@ -0,0 +1,221 @@ +// Licensed to Adam Shannon under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. The Moov Authors licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +const scoresCSV = `name,score,date +a,12,2025 +b,45,2026 +c,32,2027 +` + +func TestHandleFile_Sort(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + opts FileOpts + want []Line + wantErr string + }{ + { + name: "asc by name without keep", + input: scoresCSV, + opts: FileOpts{ + Delimiter: ',', + SortKeys: []SortKey{{Name: "name"}}, + }, + want: []Line{ + {"a", "12", "2025"}, + {"b", "45", "2026"}, + {"c", "32", "2027"}, + }, + }, + { + name: "desc by score without keep", + input: scoresCSV, + opts: FileOpts{ + Delimiter: ',', + SortKeys: []SortKey{{Name: "score", Desc: true}}, + }, + want: []Line{ + {"b", "45", "2026"}, + {"c", "32", "2027"}, + {"a", "12", "2025"}, + }, + }, + { + name: "keep reorders columns then sort by score desc", + input: scoresCSV, + opts: FileOpts{ + Delimiter: ',', + KeepCols: []string{"score", "name"}, + SortKeys: []SortKey{{Name: "score", Desc: true}}, + }, + want: []Line{ + {"45", "b"}, + {"32", "c"}, + {"12", "a"}, + }, + }, + { + name: "keep in file order then sort by name asc", + input: scoresCSV, + opts: FileOpts{ + Delimiter: ',', + KeepCols: []string{"name", "score"}, + SortKeys: []SortKey{{Name: "name"}}, + }, + want: []Line{ + {"a", "12"}, + {"b", "45"}, + {"c", "32"}, + }, + }, + { + name: "multi-column desc score then asc name", + input: `name,score +c,10 +a,20 +b,10 +`, + opts: FileOpts{ + Delimiter: ',', + SortKeys: []SortKey{ + {Name: "score", Desc: true}, + {Name: "name"}, + }, + }, + want: []Line{ + {"a", "20"}, + {"b", "10"}, + {"c", "10"}, + }, + }, + { + name: "stable on ties", + input: `name,group +first,x +second,x +third,y +`, + opts: FileOpts{ + Delimiter: ',', + SortKeys: []SortKey{{Name: "group"}}, + }, + want: []Line{ + {"first", "x"}, + {"second", "x"}, + {"third", "y"}, + }, + }, + { + name: "unknown column", + input: scoresCSV, + opts: FileOpts{ + Delimiter: ',', + SortKeys: []SortKey{{Name: "missing"}}, + }, + wantErr: `unknown sort column "missing"`, + }, + { + name: "sort by dropped keep column", + input: scoresCSV, + opts: FileOpts{ + Delimiter: ',', + KeepCols: []string{"name"}, + SortKeys: []SortKey{{Name: "score", Desc: true}}, + }, + wantErr: `unknown sort column "score"`, + }, + { + name: "numeric not lexicographic", + input: `name,score +a,9 +b,45 +c,12 +`, + opts: FileOpts{ + Delimiter: ',', + SortKeys: []SortKey{{Name: "score"}}, + }, + want: []Line{ + {"a", "9"}, + {"c", "12"}, + {"b", "45"}, + }, + }, + { + name: "case-insensitive column names", + input: scoresCSV, + opts: FileOpts{ + Delimiter: ',', + KeepCols: []string{"SCORE", "NAME"}, + SortKeys: []SortKey{{Name: "Score", Desc: true}}, + }, + want: []Line{ + {"45", "b"}, + {"32", "c"}, + {"12", "a"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := HandleFile(tt.opts, strings.NewReader(tt.input)) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, got.Lines) + }) + } +} + +func TestHandleFile_SortTestdataScores(t *testing.T) { + t.Parallel() + + fd, err := os.Open(filepath.Join("..", "..", "testdata", "scores.csv")) + require.NoError(t, err) + t.Cleanup(func() { fd.Close() }) + + got, err := HandleFile(FileOpts{ + Delimiter: ',', + KeepCols: []string{"score", "name"}, + SortKeys: []SortKey{{Name: "score", Desc: true}}, + }, fd) + require.NoError(t, err) + require.Equal(t, []Line{ + {"45", "b"}, + {"32", "c"}, + {"12", "a"}, + }, got.Lines) +} From 0b0ac5fdbbb06aca3779854973d5f7fd7a832ede Mon Sep 17 00:00:00 2001 From: Adam Shannon Date: Wed, 12 Aug 2026 21:56:30 -0500 Subject: [PATCH 4/6] docs: document -sort.asc and -sort.dsc --- README.md | 4 ++-- cmd/csvq/help.txt | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ef112bc..af43a4e 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,12 @@ go install github.com/adamdecaf/csvq/cmd/csvq@latest Extract the score and name, sort by highest score ``` -cat scores.csv | csvq -keep score,name | sort -r +csvq -keep score,name -sort.dsc score scores.csv ``` Extract first_name and last_name columns (in that order). Sort results. ``` -csvq -keep first_name,last_name ~/Downloads/report.csv | sort -u +csvq -keep first_name,last_name -sort.asc last_name,first_name ~/Downloads/report.csv ``` Change delimiter used in `report.csv`. diff --git a/cmd/csvq/help.txt b/cmd/csvq/help.txt index 50207e8..841969c 100644 --- a/cmd/csvq/help.txt +++ b/cmd/csvq/help.txt @@ -7,6 +7,8 @@ Flags: -d, -delimiter string Field delimiter (default: ",") -keep string Comma-separated list of column names to keep (order is preserved in output) + -sort.asc string Comma-separated columns to sort by (ascending) + -sort.dsc string Comma-separated columns to sort by (descending) -headers Include header row in output (default: false) -format string Output format Options: csv, tabs, table, json, jsonl (default: csv) @@ -20,6 +22,9 @@ Examples: # Same, but output as nice table csvq -keep first_name,last_name -format table users.csv + # Highest score first (numeric), then name + csvq -keep score,name -sort.dsc score testdata/scores.csv + # Change input delimiter (e.g. semicolon) csvq -d ';' -keep id,name,email accounts.csv @@ -35,5 +40,8 @@ Examples: Notes: • Column names in -keep are case-sensitive • Missing columns in -keep are silently ignored + • Sort columns must be present in the output (kept columns, or all columns) + • Mixed -sort.asc / -sort.dsc flags apply in the order given on the command line + • Numeric-looking cells are compared as numbers; everything else is a string • All input files are expected to have the same headers when using -keep • Use -format table for human-readable output during exploration From a63c386c3266ed1d39ea117bf93becf8a546ccd6 Mon Sep 17 00:00:00 2001 From: Adam Shannon Date: Wed, 12 Aug 2026 22:05:09 -0500 Subject: [PATCH 5/6] ci: run go test on Windows instead of chocolatey make make check is a no-op on Windows, and choco install is failing on chocolatey.org 504/503. Run the test suite directly so the job exercises the new sort tests. --- .github/workflows/go.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index b07d4b9..866eaed 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -22,9 +22,10 @@ jobs: - name: Check out code into the Go module directory uses: actions/checkout@v7 - - name: Install make (Windows) - if: runner.os == 'Windows' - run: choco install -y make mingw - - name: Check + if: runner.os != 'Windows' run: make check + + - name: Test (Windows) + if: runner.os == 'Windows' + run: go test ./... From 7c32d7e8852bb1feb883a57ddadc34ea966d3043 Mon Sep 17 00:00:00 2001 From: Adam Shannon Date: Wed, 12 Aug 2026 22:08:58 -0500 Subject: [PATCH 6/6] Revert "ci: run go test on Windows instead of chocolatey make" This reverts commit a63c386c3266ed1d39ea117bf93becf8a546ccd6. --- .github/workflows/go.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 866eaed..b07d4b9 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -22,10 +22,9 @@ jobs: - name: Check out code into the Go module directory uses: actions/checkout@v7 + - name: Install make (Windows) + if: runner.os == 'Windows' + run: choco install -y make mingw + - name: Check - if: runner.os != 'Windows' run: make check - - - name: Test (Windows) - if: runner.os == 'Windows' - run: go test ./...