mirror of
https://github.com/go-task/task.git
synced 2026-07-08 06:28:44 +00:00
feat: implement task sorting with --sort flag (#1105)
* refactor: move deepcopy into its own package * feat: add generic orderedmap implementation * refactor: implement tasks with orderedmap * feat: implement sort flag for all task outputs * refactor: implement vars with orderedmap * chore: docs * fix: linting issues * fix: non deterministic behavior in tests
This commit is contained in:
35
internal/deepcopy/deepcopy.go
Normal file
35
internal/deepcopy/deepcopy.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package deepcopy
|
||||
|
||||
type Copier[T any] interface {
|
||||
DeepCopy() T
|
||||
}
|
||||
|
||||
func Slice[T any](orig []T) []T {
|
||||
if orig == nil {
|
||||
return nil
|
||||
}
|
||||
c := make([]T, len(orig))
|
||||
for i, v := range orig {
|
||||
if copyable, ok := any(v).(Copier[T]); ok {
|
||||
c[i] = copyable.DeepCopy()
|
||||
} else {
|
||||
c[i] = v
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func Map[K comparable, V any](orig map[K]V) map[K]V {
|
||||
if orig == nil {
|
||||
return nil
|
||||
}
|
||||
c := make(map[K]V, len(orig))
|
||||
for k, v := range orig {
|
||||
if copyable, ok := any(v).(Copier[V]); ok {
|
||||
c[k] = copyable.DeepCopy()
|
||||
} else {
|
||||
c[k] = v
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
164
internal/orderedmap/orderedmap.go
Normal file
164
internal/orderedmap/orderedmap.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package orderedmap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/exp/constraints"
|
||||
"golang.org/x/exp/maps"
|
||||
"golang.org/x/exp/slices"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/go-task/task/v3/internal/deepcopy"
|
||||
)
|
||||
|
||||
// An OrderedMap is a wrapper around a regular map that maintains an ordered
|
||||
// list of the map's keys. This allows you to run deterministic and ordered
|
||||
// operations on the map such as printing/serializing/iterating.
|
||||
type OrderedMap[K constraints.Ordered, V any] struct {
|
||||
s []K
|
||||
m map[K]V
|
||||
}
|
||||
|
||||
// New will create a new OrderedMap of the given type and return it.
|
||||
func New[K constraints.Ordered, V any]() OrderedMap[K, V] {
|
||||
return OrderedMap[K, V]{
|
||||
s: make([]K, 0),
|
||||
m: make(map[K]V),
|
||||
}
|
||||
}
|
||||
|
||||
// FromMap will create a new OrderedMap from the given map. Since Golang maps
|
||||
// are unordered, the order of the created OrderedMap will be random.
|
||||
func FromMap[K constraints.Ordered, V any](m map[K]V) OrderedMap[K, V] {
|
||||
om := New[K, V]()
|
||||
om.m = m
|
||||
om.s = maps.Keys(m)
|
||||
return om
|
||||
}
|
||||
|
||||
func FromMapWithOrder[K constraints.Ordered, V any](m map[K]V, order []K) OrderedMap[K, V] {
|
||||
om := New[K, V]()
|
||||
if len(m) != len(order) {
|
||||
panic("length of map and order must be equal")
|
||||
}
|
||||
om.m = m
|
||||
om.s = order
|
||||
for key := range om.m {
|
||||
if !slices.Contains(om.s, key) {
|
||||
panic("order keys must match map keys")
|
||||
}
|
||||
}
|
||||
return om
|
||||
}
|
||||
|
||||
// Len will return the number of items in the map.
|
||||
func (om *OrderedMap[K, V]) Len() int {
|
||||
return len(om.s)
|
||||
}
|
||||
|
||||
// Set will set the value for a given key.
|
||||
func (om *OrderedMap[K, V]) Set(key K, value V) {
|
||||
if om.m == nil {
|
||||
om.m = make(map[K]V)
|
||||
}
|
||||
if _, ok := om.m[key]; !ok {
|
||||
om.s = append(om.s, key)
|
||||
}
|
||||
om.m[key] = value
|
||||
}
|
||||
|
||||
// Get will return the value for a given key.
|
||||
// If the key does not exist, it will return the zero value of the value type.
|
||||
func (om *OrderedMap[K, V]) Get(key K) V {
|
||||
value, ok := om.m[key]
|
||||
if !ok {
|
||||
var zero V
|
||||
return zero
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// Exists will return whether or not the given key exists.
|
||||
func (om *OrderedMap[K, V]) Exists(key K) bool {
|
||||
_, ok := om.m[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Sort will sort the map.
|
||||
func (om *OrderedMap[K, V]) Sort() {
|
||||
slices.Sort(om.s)
|
||||
}
|
||||
|
||||
// SortFunc will sort the map using the given function.
|
||||
func (om *OrderedMap[K, V]) SortFunc(less func(i, j K) bool) {
|
||||
slices.SortFunc(om.s, less)
|
||||
}
|
||||
|
||||
// Keys will return a slice of the map's keys in order.
|
||||
func (om *OrderedMap[K, V]) Keys() []K {
|
||||
return om.s
|
||||
}
|
||||
|
||||
// Values will return a slice of the map's values in order.
|
||||
func (om *OrderedMap[K, V]) Values() []V {
|
||||
var values []V
|
||||
for _, key := range om.s {
|
||||
values = append(values, om.m[key])
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// Range will iterate over the map and call the given function for each key/value.
|
||||
func (om *OrderedMap[K, V]) Range(fn func(key K, value V) error) error {
|
||||
for _, key := range om.s {
|
||||
if err := fn(key, om.m[key]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Merge merges the given Vars into the caller one
|
||||
func (om *OrderedMap[K, V]) Merge(other OrderedMap[K, V]) {
|
||||
// nolint: errcheck
|
||||
other.Range(func(key K, value V) error {
|
||||
om.Set(key, value)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (om *OrderedMap[K, V]) DeepCopy() OrderedMap[K, V] {
|
||||
return OrderedMap[K, V]{
|
||||
s: deepcopy.Slice(om.s),
|
||||
m: deepcopy.Map(om.m),
|
||||
}
|
||||
}
|
||||
|
||||
func (om *OrderedMap[K, V]) UnmarshalYAML(node *yaml.Node) error {
|
||||
switch node.Kind {
|
||||
// Even numbers contain the keys
|
||||
// Odd numbers contain the values
|
||||
case yaml.MappingNode:
|
||||
for i := 0; i < len(node.Content); i += 2 {
|
||||
// Decode the key
|
||||
keyNode := node.Content[i]
|
||||
var k K
|
||||
if err := keyNode.Decode(&k); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Decode the value
|
||||
valueNode := node.Content[i+1]
|
||||
var v V
|
||||
if err := valueNode.Decode(&v); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set the key and value
|
||||
om.Set(k, v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("yaml: line %d: cannot unmarshal %s into variables", node.Line, node.ShortTag())
|
||||
}
|
||||
121
internal/orderedmap/orderedmap_test.go
Normal file
121
internal/orderedmap/orderedmap_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package orderedmap
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestFromMap(t *testing.T) {
|
||||
m := map[int]string{3: "three", 1: "one", 2: "two"}
|
||||
om := FromMap(m)
|
||||
assert.Len(t, om.m, 3)
|
||||
assert.Len(t, om.s, 3)
|
||||
assert.ElementsMatch(t, []int{1, 2, 3}, om.s)
|
||||
for key, value := range m {
|
||||
assert.Equal(t, om.Get(key), value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGetExists(t *testing.T) {
|
||||
om := New[int, string]()
|
||||
assert.False(t, om.Exists(1))
|
||||
assert.Equal(t, "", om.Get(1))
|
||||
om.Set(1, "one")
|
||||
assert.True(t, om.Exists(1))
|
||||
assert.Equal(t, "one", om.Get(1))
|
||||
}
|
||||
|
||||
func TestSort(t *testing.T) {
|
||||
om := New[int, string]()
|
||||
om.Set(3, "three")
|
||||
om.Set(1, "one")
|
||||
om.Set(2, "two")
|
||||
om.Sort()
|
||||
assert.Equal(t, []int{1, 2, 3}, om.s)
|
||||
}
|
||||
|
||||
func TestSortFunc(t *testing.T) {
|
||||
om := New[int, string]()
|
||||
om.Set(3, "three")
|
||||
om.Set(1, "one")
|
||||
om.Set(2, "two")
|
||||
om.SortFunc(func(i, j int) bool {
|
||||
return i > j
|
||||
})
|
||||
assert.Equal(t, []int{3, 2, 1}, om.s)
|
||||
}
|
||||
|
||||
func TestKeysValues(t *testing.T) {
|
||||
om := New[int, string]()
|
||||
om.Set(3, "three")
|
||||
om.Set(1, "one")
|
||||
om.Set(2, "two")
|
||||
assert.Equal(t, []int{3, 1, 2}, om.Keys())
|
||||
assert.Equal(t, []string{"three", "one", "two"}, om.Values())
|
||||
}
|
||||
|
||||
func Range(t *testing.T) {
|
||||
om := New[int, string]()
|
||||
om.Set(3, "three")
|
||||
om.Set(1, "one")
|
||||
om.Set(2, "two")
|
||||
|
||||
expectedKeys := []int{3, 1, 2}
|
||||
expectedValues := []string{"three", "one", "two"}
|
||||
|
||||
keys := make([]int, 0, len(expectedKeys))
|
||||
values := make([]string, 0, len(expectedValues))
|
||||
|
||||
err := om.Range(func(key int, value string) error {
|
||||
keys = append(keys, key)
|
||||
values = append(values, value)
|
||||
return nil
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.ElementsMatch(t, expectedKeys, keys)
|
||||
assert.ElementsMatch(t, expectedValues, values)
|
||||
}
|
||||
|
||||
func TestOrderedMapMerge(t *testing.T) {
|
||||
om1 := New[string, int]()
|
||||
om1.Set("a", 1)
|
||||
om1.Set("b", 2)
|
||||
|
||||
om2 := New[string, int]()
|
||||
om2.Set("b", 3)
|
||||
om2.Set("c", 4)
|
||||
|
||||
om1.Merge(om2)
|
||||
|
||||
expectedKeys := []string{"a", "b", "c"}
|
||||
expectedValues := []int{1, 3, 4}
|
||||
|
||||
assert.Equal(t, len(expectedKeys), len(om1.s))
|
||||
assert.Equal(t, len(expectedKeys), len(om1.m))
|
||||
|
||||
for i, key := range expectedKeys {
|
||||
assert.True(t, om1.Exists(key))
|
||||
assert.Equal(t, expectedValues[i], om1.Get(key))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalYAML(t *testing.T) {
|
||||
yamlString := `
|
||||
3: three
|
||||
1: one
|
||||
2: two
|
||||
`
|
||||
var om OrderedMap[int, string]
|
||||
err := yaml.Unmarshal([]byte(yamlString), &om)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedKeys := []int{3, 1, 2}
|
||||
expectedValues := []string{"three", "one", "two"}
|
||||
|
||||
assert.Equal(t, expectedKeys, om.Keys())
|
||||
assert.Equal(t, expectedValues, om.Values())
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/go-task/task/v3/internal/orderedmap"
|
||||
"github.com/go-task/task/v3/internal/output"
|
||||
"github.com/go-task/task/v3/internal/templater"
|
||||
"github.com/go-task/task/v3/taskfile"
|
||||
@@ -47,10 +48,9 @@ func TestGroup(t *testing.T) {
|
||||
func TestGroupWithBeginEnd(t *testing.T) {
|
||||
tmpl := templater.Templater{
|
||||
Vars: &taskfile.Vars{
|
||||
Keys: []string{"VAR1"},
|
||||
Mapping: map[string]taskfile.Var{
|
||||
OrderedMap: orderedmap.FromMap(map[string]taskfile.Var{
|
||||
"VAR1": {Static: "example-value"},
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
44
internal/sort/sorter.go
Normal file
44
internal/sort/sorter.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package sort
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/go-task/task/v3/taskfile"
|
||||
)
|
||||
|
||||
type TaskSorter interface {
|
||||
Sort([]*taskfile.Task)
|
||||
}
|
||||
|
||||
type Noop struct{}
|
||||
|
||||
func (s *Noop) Sort(tasks []*taskfile.Task) {}
|
||||
|
||||
type AlphaNumeric struct{}
|
||||
|
||||
// Tasks that are not namespaced should be listed before tasks that are.
|
||||
// We detect this by searching for a ':' in the task name.
|
||||
func (s *AlphaNumeric) Sort(tasks []*taskfile.Task) {
|
||||
sort.Slice(tasks, func(i, j int) bool {
|
||||
return tasks[i].Task < tasks[j].Task
|
||||
})
|
||||
}
|
||||
|
||||
type AlphaNumericWithRootTasksFirst struct{}
|
||||
|
||||
// Tasks that are not namespaced should be listed before tasks that are.
|
||||
// We detect this by searching for a ':' in the task name.
|
||||
func (s *AlphaNumericWithRootTasksFirst) Sort(tasks []*taskfile.Task) {
|
||||
sort.Slice(tasks, func(i, j int) bool {
|
||||
iContainsColon := strings.Contains(tasks[i].Task, ":")
|
||||
jContainsColon := strings.Contains(tasks[j].Task, ":")
|
||||
if iContainsColon == jContainsColon {
|
||||
return tasks[i].Task < tasks[j].Task
|
||||
}
|
||||
if !iContainsColon && jContainsColon {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
77
internal/sort/sorter_test.go
Normal file
77
internal/sort/sorter_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package sort
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/go-task/task/v3/taskfile"
|
||||
)
|
||||
|
||||
func TestAlphaNumericWithRootTasksFirst_Sort(t *testing.T) {
|
||||
task1 := &taskfile.Task{Task: "task1"}
|
||||
task2 := &taskfile.Task{Task: "task2"}
|
||||
task3 := &taskfile.Task{Task: "ns1:task3"}
|
||||
task4 := &taskfile.Task{Task: "ns2:task4"}
|
||||
task5 := &taskfile.Task{Task: "task5"}
|
||||
task6 := &taskfile.Task{Task: "ns3:task6"}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tasks []*taskfile.Task
|
||||
want []*taskfile.Task
|
||||
}{
|
||||
{
|
||||
name: "no namespace tasks sorted alphabetically first",
|
||||
tasks: []*taskfile.Task{task3, task2, task1},
|
||||
want: []*taskfile.Task{task1, task2, task3},
|
||||
},
|
||||
{
|
||||
name: "namespace tasks sorted alphabetically after non-namespaced tasks",
|
||||
tasks: []*taskfile.Task{task3, task4, task5},
|
||||
want: []*taskfile.Task{task5, task3, task4},
|
||||
},
|
||||
{
|
||||
name: "all tasks sorted alphabetically with root tasks first",
|
||||
tasks: []*taskfile.Task{task6, task5, task4, task3, task2, task1},
|
||||
want: []*taskfile.Task{task1, task2, task5, task3, task4, task6},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := &AlphaNumericWithRootTasksFirst{}
|
||||
s.Sort(tt.tasks)
|
||||
assert.Equal(t, tt.want, tt.tasks)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlphaNumeric_Sort(t *testing.T) {
|
||||
task1 := &taskfile.Task{Task: "task1"}
|
||||
task2 := &taskfile.Task{Task: "task2"}
|
||||
task3 := &taskfile.Task{Task: "ns1:task3"}
|
||||
task4 := &taskfile.Task{Task: "ns2:task4"}
|
||||
task5 := &taskfile.Task{Task: "task5"}
|
||||
task6 := &taskfile.Task{Task: "ns3:task6"}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tasks []*taskfile.Task
|
||||
want []*taskfile.Task
|
||||
}{
|
||||
{
|
||||
name: "all tasks sorted alphabetically",
|
||||
tasks: []*taskfile.Task{task3, task2, task5, task1, task4, task6},
|
||||
want: []*taskfile.Task{task3, task4, task6, task1, task2, task5},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := &AlphaNumeric{}
|
||||
s.Sort(tt.tasks)
|
||||
assert.Equal(t, tt.tasks, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
func PrintTasks(l *logger.Logger, t *taskfile.Taskfile, c []taskfile.Call) {
|
||||
for i, call := range c {
|
||||
PrintSpaceBetweenSummaries(l, i)
|
||||
PrintTask(l, t.Tasks[call.Task])
|
||||
PrintTask(l, t.Tasks.Get(call.Task))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -156,10 +156,10 @@ func TestPrintAllWithSpaces(t *testing.T) {
|
||||
t2 := &taskfile.Task{Task: "t2"}
|
||||
t3 := &taskfile.Task{Task: "t3"}
|
||||
|
||||
tasks := make(taskfile.Tasks, 3)
|
||||
tasks["t1"] = t1
|
||||
tasks["t2"] = t2
|
||||
tasks["t3"] = t3
|
||||
tasks := taskfile.Tasks{}
|
||||
tasks.Set("t1", t1)
|
||||
tasks.Set("t2", t2)
|
||||
tasks.Set("t3", t3)
|
||||
|
||||
summary.PrintTasks(&l,
|
||||
&taskfile.Taskfile{Tasks: tasks},
|
||||
|
||||
Reference in New Issue
Block a user