From 4ae30718452c3b24e416b5275b8a243c5fcbf091 Mon Sep 17 00:00:00 2001 From: Pete Davison Date: Thu, 11 Sep 2025 10:26:59 +0100 Subject: [PATCH] feat: nested json (#2415) * feat: nested json * feat: remove up_to_date from json output when --no-status flag is set * feat: restrict use of --nested with --json and --list/--list-all --- cmd/task/task.go | 1 + help.go | 65 +++++++++++++++++++--------------- internal/editors/output.go | 71 +++++++++++++++++++++++++++++++++++--- internal/flags/flags.go | 6 ++++ 4 files changed, 111 insertions(+), 32 deletions(-) diff --git a/cmd/task/task.go b/cmd/task/task.go index d5f2b7ba..0a31dd7a 100644 --- a/cmd/task/task.go +++ b/cmd/task/task.go @@ -128,6 +128,7 @@ func run() error { flags.ListAll, flags.ListJson, flags.NoStatus, + flags.Nested, ) if listOptions.ShouldListTasks() { if flags.Silent { diff --git a/help.go b/help.go index 2c687a97..9998bd38 100644 --- a/help.go +++ b/help.go @@ -24,15 +24,17 @@ type ListOptions struct { ListAllTasks bool FormatTaskListAsJSON bool NoStatus bool + Nested bool } // NewListOptions creates a new ListOptions instance -func NewListOptions(list, listAll, listAsJson, noStatus bool) ListOptions { +func NewListOptions(list, listAll, listAsJson, noStatus, nested bool) ListOptions { return ListOptions{ ListOnlyTasksWithDescriptions: list, ListAllTasks: listAll, FormatTaskListAsJSON: listAsJson, NoStatus: noStatus, + Nested: nested, } } @@ -63,7 +65,7 @@ func (e *Executor) ListTasks(o ListOptions) (bool, error) { return false, err } if o.FormatTaskListAsJSON { - output, err := e.ToEditorOutput(tasks, o.NoStatus) + output, err := e.ToEditorOutput(tasks, o.NoStatus, o.Nested) if err != nil { return false, err } @@ -135,33 +137,17 @@ func (e *Executor) ListTaskNames(allTasks bool) error { return nil } -func (e *Executor) ToEditorOutput(tasks []*ast.Task, noStatus bool) (*editors.Taskfile, error) { - o := &editors.Taskfile{ - Tasks: make([]editors.Task, len(tasks)), - Location: e.Taskfile.Location, - } +func (e *Executor) ToEditorOutput(tasks []*ast.Task, noStatus bool, nested bool) (*editors.Namespace, error) { var g errgroup.Group + editorTasks := make([]editors.Task, len(tasks)) + + // Look over each task in parallel and turn it into an editor task for i := range tasks { - aliases := []string{} - if len(tasks[i].Aliases) > 0 { - aliases = tasks[i].Aliases - } g.Go(func() error { - o.Tasks[i] = editors.Task{ - Name: tasks[i].Name(), - Task: tasks[i].Task, - Desc: tasks[i].Desc, - Summary: tasks[i].Summary, - Aliases: aliases, - UpToDate: false, - Location: &editors.Location{ - Line: tasks[i].Location.Line, - Column: tasks[i].Location.Column, - Taskfile: tasks[i].Location.Taskfile, - }, - } + editorTask := editors.NewTask(tasks[i]) if noStatus { + editorTasks[i] = editorTask return nil } @@ -180,10 +166,35 @@ func (e *Executor) ToEditorOutput(tasks []*ast.Task, noStatus bool) (*editors.Ta return err } - o.Tasks[i].UpToDate = upToDate - + editorTask.UpToDate = &upToDate + editorTasks[i] = editorTask return nil }) } - return o, g.Wait() + if err := g.Wait(); err != nil { + return nil, err + } + + // Create the root namespace + var tasksLen int + if !nested { + tasksLen = len(editorTasks) + } + rootNamespace := &editors.Namespace{ + Tasks: make([]editors.Task, tasksLen), + Location: e.Taskfile.Location, + } + + // Recursively add namespaces to the root namespace or if nesting is + // disabled add them all to the root namespace + for i, task := range editorTasks { + taskNamespacePath := strings.Split(task.Task, ast.NamespaceSeparator) + if nested { + rootNamespace.AddNamespace(taskNamespacePath, task) + } else { + rootNamespace.Tasks[i] = task + } + } + + return rootNamespace, g.Wait() } diff --git a/internal/editors/output.go b/internal/editors/output.go index 9a6090b3..eff0a0cb 100644 --- a/internal/editors/output.go +++ b/internal/editors/output.go @@ -1,10 +1,15 @@ package editors +import ( + "github.com/go-task/task/v3/taskfile/ast" +) + type ( - // Taskfile wraps task list output for use in editor integrations (e.g. VSCode, etc) - Taskfile struct { - Tasks []Task `json:"tasks"` - Location string `json:"location"` + // Namespace wraps task list output for use in editor integrations (e.g. VSCode, etc) + Namespace struct { + Tasks []Task `json:"tasks"` + Namespaces map[string]*Namespace `json:"namespaces,omitempty"` + Location string `json:"location,omitempty"` } // Task describes a single task Task struct { @@ -13,7 +18,7 @@ type ( Desc string `json:"desc"` Summary string `json:"summary"` Aliases []string `json:"aliases"` - UpToDate bool `json:"up_to_date"` + UpToDate *bool `json:"up_to_date,omitempty"` Location *Location `json:"location"` } // Location describes a task's location in a taskfile @@ -23,3 +28,59 @@ type ( Taskfile string `json:"taskfile"` } ) + +func NewTask(task *ast.Task) Task { + aliases := []string{} + if len(task.Aliases) > 0 { + aliases = task.Aliases + } + return Task{ + Name: task.Name(), + Task: task.Task, + Desc: task.Desc, + Summary: task.Summary, + Aliases: aliases, + Location: &Location{ + Line: task.Location.Line, + Column: task.Location.Column, + Taskfile: task.Location.Taskfile, + }, + } +} + +func (parent *Namespace) AddNamespace(namespacePath []string, task Task) { + if len(namespacePath) == 0 { + return + } + + // If there are no child namespaces, then we have found a task and we can + // simply add it to the current namespace + if len(namespacePath) == 1 { + parent.Tasks = append(parent.Tasks, task) + return + } + + // Get the key of the current namespace in the path + namespaceKey := namespacePath[0] + + // Add the namespace to the parent namespaces map using the namespace key + if parent.Namespaces == nil { + parent.Namespaces = make(map[string]*Namespace, 0) + } + + // Search for the current namespace in the parent namespaces map + // If it doesn't exist, create it + namespace, ok := parent.Namespaces[namespaceKey] + if !ok { + namespace = &Namespace{} + parent.Namespaces[namespaceKey] = namespace + } + + // Remove the current namespace key from the namespace path. + childNamespacePath := namespacePath[1:] + + // If there are no child namespaces in the task name, then we have found the + // namespace of the task and we can add it to the current namespace. + // Otherwise, we need to go deeper + namespace.AddNamespace(childNamespacePath, task) +} diff --git a/internal/flags/flags.go b/internal/flags/flags.go index f76620ad..168ab46f 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -51,6 +51,7 @@ var ( TaskSort string Status bool NoStatus bool + Nested bool Insecure bool Force bool ForceAll bool @@ -117,6 +118,7 @@ func init() { pflag.StringVar(&TaskSort, "sort", "", "Changes the order of the tasks when listed. [default|alphanumeric|none].") pflag.BoolVar(&Status, "status", false, "Exits with non-zero exit code if any of the given tasks is not up-to-date.") pflag.BoolVar(&NoStatus, "no-status", false, "Ignore status when listing tasks as JSON") + pflag.BoolVar(&Nested, "nested", false, "Nest namespaces when listing tasks as JSON") pflag.BoolVar(&Insecure, "insecure", getConfig(config, config.Remote.Insecure, false), "Forces Task to download Taskfiles over insecure connections.") pflag.BoolVarP(&Watch, "watch", "w", false, "Enables watch of the given task.") pflag.BoolVarP(&Verbose, "verbose", "v", getConfig(config, config.Verbose, false), "Enables verbose mode.") @@ -194,6 +196,10 @@ func Validate() error { return errors.New("task: --no-status only applies to --json with --list or --list-all") } + if Nested && !ListJson { + return errors.New("task: --nested only applies to --json with --list or --list-all") + } + return nil }