diff --git a/cmd/task/task.go b/cmd/task/task.go index b81e23dd5f..a90f686f14 100644 --- a/cmd/task/task.go +++ b/cmd/task/task.go @@ -22,24 +22,24 @@ import ( func main() { if err := run(); err != nil { - l := &logger.Logger{ + l := logger.NewLogger(logger.LoggerOptions{ Stdout: os.Stdout, Stderr: os.Stderr, Verbose: flags.Verbose, Color: flags.Color, - } + }) if err, ok := err.(*errors.TaskRunError); ok && flags.ExitCode { emitCIErrorAnnotation(err) - l.Errf(logger.Red, "%v\n", err) + l.Errorf("%v\n", err) os.Exit(err.TaskExitCode()) } if err, ok := err.(errors.TaskError); ok { emitCIErrorAnnotation(err) - l.Errf(logger.Red, "%v\n", err) + l.Errorf("%v\n", err) os.Exit(err.Code()) } emitCIErrorAnnotation(err) - l.Errf(logger.Red, "%v\n", err) + l.Errorf("%v\n", err) os.Exit(errors.CodeUnknown) } os.Exit(errors.CodeOk) @@ -58,12 +58,13 @@ func emitCIErrorAnnotation(err error) { } func run() error { - log := &logger.Logger{ - Stdout: os.Stdout, - Stderr: os.Stderr, - Verbose: flags.Verbose, - Color: flags.Color, - } + log := logger.NewLogger(logger.LoggerOptions{ + Stdout: os.Stdout, + Stderr: os.Stderr, + Verbose: flags.Verbose, + Color: flags.Color, + LogFormat: flags.LogFormat, + }) if err := flags.Validate(); err != nil { return err @@ -109,10 +110,8 @@ func run() error { return err } if !flags.Silent { - if flags.Verbose { - log.Outf(logger.Default, "%s\n", task.DefaultTaskfile) - } - log.Outf(logger.Green, "Taskfile created: %s\n", filepathext.TryAbsToRel(finalPath)) + log.VerboseOutf(logger.Default, "%s\n", task.DefaultTaskfile) + log.OutfDirect(logger.Green, "Taskfile created: %s\n", filepathext.TryAbsToRel(finalPath)) } return nil } diff --git a/executor.go b/executor.go index 03b951a166..41930f19fc 100644 --- a/executor.go +++ b/executor.go @@ -51,6 +51,7 @@ type ( Concurrency int Interval time.Duration Failfast bool + LogFormat string // I/O Stdin io.Reader @@ -560,3 +561,16 @@ type failfastOption struct { func (o *failfastOption) ApplyToExecutor(e *Executor) { e.Failfast = o.failfast } + +// WithLogFormat sets the log format. +func WithLogFormat(logFormat string) ExecutorOption { + return &logFormatOption{logFormat: logFormat} +} + +type logFormatOption struct { + logFormat string +} + +func (o *logFormatOption) ApplyToExecutor(e *Executor) { + e.LogFormat = o.logFormat +} diff --git a/help.go b/help.go index 9998bd38ad..dabb0f993a 100644 --- a/help.go +++ b/help.go @@ -80,13 +80,13 @@ func (e *Executor) ListTasks(o ListOptions) (bool, error) { } if len(tasks) == 0 { if o.ListOnlyTasksWithDescriptions { - e.Logger.Outf(logger.Yellow, "task: No tasks with description available. Try --list-all to list all tasks\n") + e.Logger.OutfDirect(logger.Yellow, "task: No tasks with description available. Try --list-all to list all tasks\n") } else if o.ListAllTasks { - e.Logger.Outf(logger.Yellow, "task: No tasks available\n") + e.Logger.OutfDirect(logger.Yellow, "task: No tasks available\n") } return false, nil } - e.Logger.Outf(logger.Default, "task: Available tasks for this project:\n") + e.Logger.OutfDirect(logger.Default, "task: Available tasks for this project:\n") // Format in tab-separated columns with a tab stop of 8. w := tabwriter.NewWriter(e.Stdout, 0, 8, 6, ' ', 0) diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 6019e51d5f..f28965e404 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -83,6 +83,7 @@ var ( Timeout time.Duration CacheExpiryDuration time.Duration RemoteCacheDir string + LogFormat string ) func init() { @@ -148,6 +149,7 @@ func init() { pflag.BoolVarP(&Failfast, "failfast", "F", getConfig(config, func() *bool { return &config.Failfast }, false), "When running tasks in parallel, stop all tasks if one fails.") pflag.BoolVarP(&Global, "global", "g", false, "Runs global Taskfile, from $HOME/{T,t}askfile.{yml,yaml}.") pflag.BoolVar(&Experiments, "experiments", false, "Lists all the available experiments and whether or not they are enabled.") + pflag.StringVar(&LogFormat, "log", "", `Log format ("json" or "text").`) // Gentle force experiment will override the force flag and add a new force-all flag if experiments.GentleForce.Enabled() { @@ -291,6 +293,7 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) { task.WithTaskSorter(sorter), task.WithVersionCheck(true), task.WithFailfast(Failfast), + task.WithLogFormat(LogFormat), ) } diff --git a/internal/logger/color.go b/internal/logger/color.go new file mode 100644 index 0000000000..81637d05c1 --- /dev/null +++ b/internal/logger/color.go @@ -0,0 +1,112 @@ +package logger + +import ( + "io" + "slices" + "strconv" + "strings" + + "github.com/fatih/color" + + "github.com/go-task/task/v3/internal/env" +) + +var ( + attrsReset = envColor("COLOR_RESET", color.Reset) + attrsFgBlue = envColor("COLOR_BLUE", color.FgBlue) + attrsFgGreen = envColor("COLOR_GREEN", color.FgGreen) + attrsFgCyan = envColor("COLOR_CYAN", color.FgCyan) + attrsFgYellow = envColor("COLOR_YELLOW", color.FgYellow) + attrsFgMagenta = envColor("COLOR_MAGENTA", color.FgMagenta) + attrsFgRed = envColor("COLOR_RED", color.FgRed) + attrsFgHiBlue = envColor("COLOR_BRIGHT_BLUE", color.FgHiBlue) + attrsFgHiGreen = envColor("COLOR_BRIGHT_GREEN", color.FgHiGreen) + attrsFgHiCyan = envColor("COLOR_BRIGHT_CYAN", color.FgHiCyan) + attrsFgHiYellow = envColor("COLOR_BRIGHT_YELLOW", color.FgHiYellow) + attrsFgHiMagenta = envColor("COLOR_BRIGHT_MAGENTA", color.FgHiMagenta) + attrsFgHiRed = envColor("COLOR_BRIGHT_RED", color.FgHiRed) +) + +type ( + Color func() PrintFunc + PrintFunc func(io.Writer, string, ...any) +) + +func Default() PrintFunc { + return color.New(attrsReset...).FprintfFunc() +} + +func Blue() PrintFunc { + return color.New(attrsFgBlue...).FprintfFunc() +} + +func Green() PrintFunc { + return color.New(attrsFgGreen...).FprintfFunc() +} + +func Cyan() PrintFunc { + return color.New(attrsFgCyan...).FprintfFunc() +} + +func Yellow() PrintFunc { + return color.New(attrsFgYellow...).FprintfFunc() +} + +func Magenta() PrintFunc { + return color.New(attrsFgMagenta...).FprintfFunc() +} + +func Red() PrintFunc { + return color.New(attrsFgRed...).FprintfFunc() +} + +func BrightBlue() PrintFunc { + return color.New(attrsFgHiBlue...).FprintfFunc() +} + +func BrightGreen() PrintFunc { + return color.New(attrsFgHiGreen...).FprintfFunc() +} + +func BrightCyan() PrintFunc { + return color.New(attrsFgHiCyan...).FprintfFunc() +} + +func BrightYellow() PrintFunc { + return color.New(attrsFgHiYellow...).FprintfFunc() +} + +func BrightMagenta() PrintFunc { + return color.New(attrsFgHiMagenta...).FprintfFunc() +} + +func BrightRed() PrintFunc { + return color.New(attrsFgHiRed...).FprintfFunc() +} + +func envColor(name string, defaultColor color.Attribute) []color.Attribute { + // Fetch the environment variable + override := env.GetTaskEnv(name) + + // First, try splitting the string by commas (RGB shortcut syntax) and if it + // matches, then prepend the 256-color foreground escape sequence. + // Otherwise, split by semicolons (ANSI color codes) and use them as is. + attributeStrs := strings.Split(override, ",") + if len(attributeStrs) == 3 { + attributeStrs = slices.Concat([]string{"38", "2"}, attributeStrs) + } else { + attributeStrs = strings.Split(override, ";") + } + + // Loop over the attributes and convert them to integers + attributes := make([]color.Attribute, len(attributeStrs)) + for i, attributeStr := range attributeStrs { + attribute, err := strconv.Atoi(attributeStr) + if err != nil { + return []color.Attribute{defaultColor} + } + attributes[i] = color.Attribute(attribute) + } + + return attributes +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 6ebcf7d9d6..f7583be68c 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -2,147 +2,171 @@ package logger import ( "bufio" + "context" + "fmt" "io" + "log/slog" "slices" - "strconv" "strings" "github.com/Ladicle/tabwriter" - "github.com/fatih/color" "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/experiments" - "github.com/go-task/task/v3/internal/env" "github.com/go-task/task/v3/internal/term" ) -var ( - ErrPromptCancelled = errors.New("prompt cancelled") - ErrNoTerminal = errors.New("no terminal") +const ( + LevelTaskVerbose = slog.LevelDebug // VerboseOutf -> stdout + LevelTaskVerboseErr = slog.LevelDebug + 1 // VerboseErrf -> stderr + LevelTaskInfo = slog.LevelInfo // Outf -> stdout + LevelTaskInfoErr = slog.LevelInfo + 1 // Errf -> stderr + LevelTask = slog.LevelInfo + 2 // Taskf -> (only json/text) + LevelTaskWarning = slog.LevelWarn // Warnf(yellow) -> stderr + LevelTaskError = slog.LevelError // Errorf(red) -> stderr ) +var levelNames = map[slog.Leveler]string{ + LevelTaskVerbose: "VERBOSE", + LevelTaskVerboseErr: "VERBOSE", + LevelTaskInfo: "INFO", + LevelTaskInfoErr: "INFO", + LevelTask: "TASK", +} + var ( - attrsReset = envColor("COLOR_RESET", color.Reset) - attrsFgBlue = envColor("COLOR_BLUE", color.FgBlue) - attrsFgGreen = envColor("COLOR_GREEN", color.FgGreen) - attrsFgCyan = envColor("COLOR_CYAN", color.FgCyan) - attrsFgYellow = envColor("COLOR_YELLOW", color.FgYellow) - attrsFgMagenta = envColor("COLOR_MAGENTA", color.FgMagenta) - attrsFgRed = envColor("COLOR_RED", color.FgRed) - attrsFgHiBlue = envColor("COLOR_BRIGHT_BLUE", color.FgHiBlue) - attrsFgHiGreen = envColor("COLOR_BRIGHT_GREEN", color.FgHiGreen) - attrsFgHiCyan = envColor("COLOR_BRIGHT_CYAN", color.FgHiCyan) - attrsFgHiYellow = envColor("COLOR_BRIGHT_YELLOW", color.FgHiYellow) - attrsFgHiMagenta = envColor("COLOR_BRIGHT_MAGENTA", color.FgHiMagenta) - attrsFgHiRed = envColor("COLOR_BRIGHT_RED", color.FgHiRed) + ErrPromptCancelled = errors.New("prompt cancelled") + ErrNoTerminal = errors.New("no terminal") + colorKey = contextColorKey("color") ) type ( - Color func() PrintFunc - PrintFunc func(io.Writer, string, ...any) + contextColorKey string ) -func Default() PrintFunc { - return color.New(attrsReset...).FprintfFunc() -} - -func Blue() PrintFunc { - return color.New(attrsFgBlue...).FprintfFunc() -} - -func Green() PrintFunc { - return color.New(attrsFgGreen...).FprintfFunc() +type LoggerOptions struct { + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + Verbose bool + Color bool + AssumeYes bool + AssumeTerm bool // Used for testing + LogFormat string } -func Cyan() PrintFunc { - return color.New(attrsFgCyan...).FprintfFunc() -} +// Logger is just a wrapper that prints stuff to STDOUT or STDERR, +// with optional color. +type Logger struct { + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + Verbose bool + Color bool + AssumeYes bool + AssumeTerm bool // Used for testing -func Yellow() PrintFunc { - return color.New(attrsFgYellow...).FprintfFunc() + slogger *slog.Logger } -func Magenta() PrintFunc { - return color.New(attrsFgMagenta...).FprintfFunc() -} +func NewLogger(opts LoggerOptions) *Logger { + logger := Logger{ + Stdin: opts.Stdin, + Stdout: opts.Stdout, + Stderr: opts.Stderr, + Verbose: opts.Verbose, + Color: opts.Color, + AssumeYes: opts.AssumeYes, + AssumeTerm: opts.AssumeTerm, + } + level := LevelTaskInfo + if opts.Verbose { + level = LevelTaskVerbose + } + handlerOps := TaskLogHandlerOptions{ + HandlerOptions: slog.HandlerOptions{ + Level: level, + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.LevelKey { + label, exists := levelNames[a.Value.Any().(slog.Level)] + if exists { + a.Value = slog.StringValue(label) + } + } + return a + }, + }, + Logger: &logger, + } -func Red() PrintFunc { - return color.New(attrsFgRed...).FprintfFunc() -} + switch lf := strings.ToLower(opts.LogFormat); lf { + case "json": + logger.slogger = slog.New(slog.NewJSONHandler(logger.Stdout, &handlerOps.HandlerOptions)) + case "text": + logger.slogger = slog.New(slog.NewTextHandler(logger.Stdout, &handlerOps.HandlerOptions)) + default: + logger.slogger = slog.New(NewTaskLogHandler(&handlerOps)) + } -func BrightBlue() PrintFunc { - return color.New(attrsFgHiBlue...).FprintfFunc() + return &logger } -func BrightGreen() PrintFunc { - return color.New(attrsFgHiGreen...).FprintfFunc() +func (l *Logger) IsStructured() bool { + if l.slogger != nil { + switch l.slogger.Handler().(type) { + case *slog.JSONHandler: + return true + case *slog.TextHandler: + return true + } + } + return false } -func BrightCyan() PrintFunc { - return color.New(attrsFgHiCyan...).FprintfFunc() +// Outf prints stuff to STDOUT. +func (l *Logger) Outf(color Color, s string, args ...any) { + s = fmt.Sprintf(s, args...) + l.slogger.Log(context.WithValue(context.Background(), colorKey, color), LevelTaskInfo, s) } -func BrightYellow() PrintFunc { - return color.New(attrsFgHiYellow...).FprintfFunc() +// VerboseOutf prints stuff to STDOUT if verbose mode is enabled. +func (l *Logger) VerboseOutf(color Color, s string, args ...any) { + s = fmt.Sprintf(s, args...) + l.slogger.Log(context.WithValue(context.Background(), colorKey, color), LevelTaskVerbose, s) } -func BrightMagenta() PrintFunc { - return color.New(attrsFgHiMagenta...).FprintfFunc() +// Errf prints stuff to STDERR. +func (l *Logger) Errf(color Color, s string, args ...any) { + s = fmt.Sprintf(s, args...) + l.slogger.Log(context.WithValue(context.Background(), colorKey, color), LevelTaskInfoErr, s) } -func BrightRed() PrintFunc { - return color.New(attrsFgHiRed...).FprintfFunc() +// VerboseErrf prints stuff to STDERR if verbose mode is enabled. +func (l *Logger) VerboseErrf(color Color, s string, args ...any) { + s = fmt.Sprintf(s, args...) + l.slogger.Log(context.WithValue(context.Background(), colorKey, color), LevelTaskVerboseErr, s) } -func envColor(name string, defaultColor color.Attribute) []color.Attribute { - // Fetch the environment variable - override := env.GetTaskEnv(name) - - // First, try splitting the string by commas (RGB shortcut syntax) and if it - // matches, then prepend the 256-color foreground escape sequence. - // Otherwise, split by semicolons (ANSI color codes) and use them as is. - attributeStrs := strings.Split(override, ",") - if len(attributeStrs) == 3 { - attributeStrs = slices.Concat([]string{"38", "2"}, attributeStrs) - } else { - attributeStrs = strings.Split(override, ";") - } - - // Loop over the attributes and convert them to integers - attributes := make([]color.Attribute, len(attributeStrs)) - for i, attributeStr := range attributeStrs { - attribute, err := strconv.Atoi(attributeStr) - if err != nil { - return []color.Attribute{defaultColor} - } - attributes[i] = color.Attribute(attribute) - } - - return attributes +// Taskf prints to json/text logger only, args are key/value pairs. +func (l *Logger) Taskf(message string, args ...any) { + var color Color = Cyan + l.slogger.Log(context.WithValue(context.Background(), colorKey, color), LevelTask, message, args...) } -// Logger is just a wrapper that prints stuff to STDOUT or STDERR, -// with optional color. -type Logger struct { - Stdin io.Reader - Stdout io.Writer - Stderr io.Writer - Verbose bool - Color bool - AssumeYes bool - AssumeTerm bool // Used for testing +func (l *Logger) Warnf(message string, args ...any) { + var color Color = Yellow + s := fmt.Sprintf(message, args...) + l.slogger.Log(context.WithValue(context.Background(), colorKey, color), LevelTaskWarning, s) } -// Outf prints stuff to STDOUT. -func (l *Logger) Outf(color Color, s string, args ...any) { - l.FOutf(l.Stdout, color, s, args...) +func (l *Logger) Errorf(message string, args ...any) { + var color Color = Red + s := fmt.Sprintf(message, args...) + l.slogger.Log(context.WithValue(context.Background(), colorKey, color), LevelTaskError, s) } // FOutf prints stuff to the given writer. func (l *Logger) FOutf(w io.Writer, color Color, s string, args ...any) { - if len(args) == 0 { - s, args = "%s", []any{s} - } if !l.Color { color = Default } @@ -150,39 +174,17 @@ func (l *Logger) FOutf(w io.Writer, color Color, s string, args ...any) { print(w, s, args...) } -// VerboseOutf prints stuff to STDOUT if verbose mode is enabled. -func (l *Logger) VerboseOutf(color Color, s string, args ...any) { - if l.Verbose { - l.Outf(color, s, args...) - } -} - -// Errf prints stuff to STDERR. -func (l *Logger) Errf(color Color, s string, args ...any) { - if len(args) == 0 { - s, args = "%s", []any{s} - } - if !l.Color { - color = Default - } - print := color() - print(l.Stderr, s, args...) -} - -// VerboseErrf prints stuff to STDERR if verbose mode is enabled. -func (l *Logger) VerboseErrf(color Color, s string, args ...any) { - if l.Verbose { - l.Errf(color, s, args...) - } +func (l *Logger) OutfDirect(color Color, s string, args ...any) { + l.FOutf(l.Stdout, color, s, args...) } -func (l *Logger) Warnf(message string, args ...any) { - l.Errf(Yellow, message, args...) +func (l *Logger) ErrfDirect(color Color, s string, args ...any) { + l.FOutf(l.Stderr, color, s, args...) } func (l *Logger) Prompt(color Color, prompt string, defaultValue string, continueValues ...string) error { if l.AssumeYes { - l.Outf(color, "%s [assuming yes]\n", prompt) + l.OutfDirect(color, "%s [assuming yes]\n", prompt) return nil } @@ -194,7 +196,7 @@ func (l *Logger) Prompt(color Color, prompt string, defaultValue string, continu return errors.New("no continue values provided") } - l.Outf(color, "%s [%s/%s]: ", prompt, strings.ToLower(continueValues[0]), strings.ToUpper(defaultValue)) + l.OutfDirect(color, "%s [%s/%s]: ", prompt, strings.ToLower(continueValues[0]), strings.ToUpper(defaultValue)) reader := bufio.NewReader(l.Stdin) input, err := reader.ReadString('\n') diff --git a/internal/logger/task_handler.go b/internal/logger/task_handler.go new file mode 100644 index 0000000000..0e39fbe3fc --- /dev/null +++ b/internal/logger/task_handler.go @@ -0,0 +1,89 @@ +package logger + +import ( + "context" + "log/slog" + "os" +) + +type TaskLogHandler struct { + level slog.Leveler + logger *Logger +} + +type TaskLogHandlerOptions struct { + slog.HandlerOptions + Logger *Logger +} + +func NewTaskLogHandler(opts *TaskLogHandlerOptions) *TaskLogHandler { + h := &TaskLogHandler{} + if opts != nil { + h.level = opts.Level + h.logger = opts.Logger + } + if h.level == nil { + h.level = LevelTaskInfo + } + if h.logger == nil { + // Should be an impossible condition. + h.errf(Red, "Task log handler not configured: no Logger object!") + os.Exit(1) + } + return h +} + +func (h *TaskLogHandler) Enabled(ctx context.Context, level slog.Level) bool { + return level >= h.level.Level() +} + +func (h *TaskLogHandler) Handle(ctx context.Context, r slog.Record) error { + var color Color + color, ok := ctx.Value(colorKey).(Color) + if !ok { + color = Default + } + switch { + case r.Level == LevelTask: + // NOP, only for json/text logging. + case r.Level <= LevelTaskVerbose: + h.outf(color, r.Message) + case r.Level <= LevelTaskVerboseErr: + h.errf(color, r.Message) + case r.Level <= LevelTaskInfo: + h.outf(color, r.Message) + case r.Level <= LevelTaskInfoErr: + h.errf(color, r.Message) + case r.Level <= LevelTaskWarning: + h.errf(color, r.Message) + case r.Level <= LevelTaskError: + h.errf(color, r.Message) + default: + h.errf(color, r.Message) + } + return nil +} + +func (h *TaskLogHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return nil +} + +func (h *TaskLogHandler) WithGroup(name string) slog.Handler { + return h +} + +func (h *TaskLogHandler) errf(color Color, s string, args ...any) { + if !h.logger.Color { + color = Default + } + print := color() + print(h.logger.Stderr, s) +} + +func (h *TaskLogHandler) outf(color Color, s string, args ...any) { + if !h.logger.Color { + color = Default + } + print := color() + print(h.logger.Stdout, s) +} diff --git a/internal/output/logger.go b/internal/output/logger.go new file mode 100644 index 0000000000..18fbb6f2f3 --- /dev/null +++ b/internal/output/logger.go @@ -0,0 +1,25 @@ +package output + +import ( + "bytes" + "io" + + "github.com/go-task/task/v3/internal/templater" +) + +type Logger struct{} + +func (l Logger) WrapWriter(stdOut, stdErr io.Writer, _ string, cache *templater.Cache) (io.Writer, io.Writer, CloseFunc) { + lwOut := &LoggerWriter{writer: stdOut} + lwErr := &LoggerWriter{writer: stdErr} + return lwOut, lwErr, func(error) error { return nil } +} + +type LoggerWriter struct { + writer io.Writer + Buffer bytes.Buffer +} + +func (lw *LoggerWriter) Write(p []byte) (int, error) { + return lw.Buffer.Write(p) +} diff --git a/internal/output/output.go b/internal/output/output.go index 9940f29fa8..9834ad4603 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -17,6 +17,10 @@ type CloseFunc func(err error) error // Build the Output for the requested ast.Output. func BuildFor(o *ast.Output, logger *logger.Logger) (Output, error) { + if logger.IsStructured() { + // Capture stdout/stderr for structured logging. + return Logger{}, nil + } switch o.Name { case "interleaved", "": if err := checkOutputGroupUnset(o); err != nil { diff --git a/internal/output/output_test.go b/internal/output/output_test.go index ba03c9adfa..0cf164bc3f 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -123,9 +123,9 @@ func TestGroupErrorOnlyShowsOutputOnError(t *testing.T) { func TestPrefixed(t *testing.T) { //nolint:paralleltest // cannot run in parallel var b bytes.Buffer - l := &logger.Logger{ + l := logger.NewLogger(logger.LoggerOptions{ Color: false, - } + }) var o output.Output = output.NewPrefixed(l) w, _, cleanup := o.WrapWriter(&b, io.Discard, "prefix", nil) @@ -159,9 +159,9 @@ func TestPrefixedWithColor(t *testing.T) { color.NoColor = false var b bytes.Buffer - l := &logger.Logger{ + l := logger.NewLogger(logger.LoggerOptions{ Color: true, - } + }) var o output.Output = output.NewPrefixed(l) diff --git a/internal/summary/summary.go b/internal/summary/summary.go index 86b0975e39..627c56cd68 100644 --- a/internal/summary/summary.go +++ b/internal/summary/summary.go @@ -24,8 +24,8 @@ func PrintSpaceBetweenSummaries(l *logger.Logger, i int) { return } - l.Outf(logger.Default, "\n") - l.Outf(logger.Default, "\n") + l.OutfDirect(logger.Default, "\n") + l.OutfDirect(logger.Default, "\n") } func PrintTask(l *logger.Logger, t *ast.Task) { @@ -58,26 +58,26 @@ func printTaskSummary(l *logger.Logger, t *ast.Task) { for i, line := range lines { notLastLine := i+1 < len(lines) if notLastLine || line != "" { - l.Outf(logger.Default, "%s\n", line) + l.OutfDirect(logger.Default, "%s\n", line) } } } func printTaskName(l *logger.Logger, t *ast.Task) { - l.Outf(logger.Default, "task: ") - l.Outf(logger.Green, "%s\n", t.Name()) - l.Outf(logger.Default, "\n") + l.OutfDirect(logger.Default, "task: ") + l.OutfDirect(logger.Green, "%s\n", t.Name()) + l.OutfDirect(logger.Default, "\n") } func printTaskAliases(l *logger.Logger, t *ast.Task) { if len(t.Aliases) == 0 { return } - l.Outf(logger.Default, "\n") - l.Outf(logger.Default, "aliases:\n") + l.OutfDirect(logger.Default, "\n") + l.OutfDirect(logger.Default, "aliases:\n") for _, alias := range t.Aliases { - l.Outf(logger.Default, " - ") - l.Outf(logger.Cyan, "%s\n", alias) + l.OutfDirect(logger.Default, " - ") + l.OutfDirect(logger.Cyan, "%s\n", alias) } } @@ -86,11 +86,11 @@ func hasDescription(t *ast.Task) bool { } func printTaskDescription(l *logger.Logger, t *ast.Task) { - l.Outf(logger.Default, "%s\n", t.Desc) + l.OutfDirect(logger.Default, "%s\n", t.Desc) } func printNoDescriptionOrSummary(l *logger.Logger) { - l.Outf(logger.Default, "(task does not have description or summary)\n") + l.OutfDirect(logger.Default, "(task does not have description or summary)\n") } func printTaskDependencies(l *logger.Logger, t *ast.Task) { @@ -98,11 +98,11 @@ func printTaskDependencies(l *logger.Logger, t *ast.Task) { return } - l.Outf(logger.Default, "\n") - l.Outf(logger.Default, "dependencies:\n") + l.OutfDirect(logger.Default, "\n") + l.OutfDirect(logger.Default, "dependencies:\n") for _, d := range t.Deps { - l.Outf(logger.Default, " - %s\n", d.Task) + l.OutfDirect(logger.Default, " - %s\n", d.Task) } } @@ -111,15 +111,15 @@ func printTaskCommands(l *logger.Logger, t *ast.Task) { return } - l.Outf(logger.Default, "\n") - l.Outf(logger.Default, "commands:\n") + l.OutfDirect(logger.Default, "\n") + l.OutfDirect(logger.Default, "commands:\n") for _, c := range t.Cmds { isCommand := c.Cmd != "" - l.Outf(logger.Default, " - ") + l.OutfDirect(logger.Default, " - ") if isCommand { - l.Outf(logger.Yellow, "%s\n", c.Cmd) + l.OutfDirect(logger.Yellow, "%s\n", c.Cmd) } else { - l.Outf(logger.Green, "Task: %s\n", c.Task) + l.OutfDirect(logger.Green, "Task: %s\n", c.Task) } } } @@ -150,14 +150,14 @@ func printTaskVars(l *logger.Logger, t *ast.Task) { return } - l.Outf(logger.Default, "\n") - l.Outf(logger.Default, "vars:\n") + l.OutfDirect(logger.Default, "\n") + l.OutfDirect(logger.Default, "vars:\n") for key, value := range t.Vars.All() { // Only display variables that are not from OS environment or Taskfile env if !isEnvVar(key, osEnvVars) && !taskfileEnvVars[key] { formattedValue := formatVarValue(value) - l.Outf(logger.Yellow, " %s: %s\n", key, formattedValue) + l.OutfDirect(logger.Yellow, " %s: %s\n", key, formattedValue) } } } @@ -181,14 +181,14 @@ func printTaskEnv(l *logger.Logger, t *ast.Task) { return } - l.Outf(logger.Default, "\n") - l.Outf(logger.Default, "env:\n") + l.OutfDirect(logger.Default, "\n") + l.OutfDirect(logger.Default, "env:\n") for key, value := range t.Env.All() { // Only display variables that are not from OS environment if !isEnvVar(key, envVars) { formattedValue := formatVarValue(value) - l.Outf(logger.Yellow, " %s: %s\n", key, formattedValue) + l.OutfDirect(logger.Yellow, " %s: %s\n", key, formattedValue) } } } @@ -242,21 +242,21 @@ func printTaskRequires(l *logger.Logger, t *ast.Task) { return } - l.Outf(logger.Default, "\n") - l.Outf(logger.Default, "requires:\n") - l.Outf(logger.Default, " vars:\n") + l.OutfDirect(logger.Default, "\n") + l.OutfDirect(logger.Default, "requires:\n") + l.OutfDirect(logger.Default, " vars:\n") for _, v := range t.Requires.Vars { // If the variable has enum constraints, format accordingly if len(v.Enum) > 0 { - l.Outf(logger.Yellow, " - %s:\n", v.Name) - l.Outf(logger.Yellow, " enum:\n") + l.OutfDirect(logger.Yellow, " - %s:\n", v.Name) + l.OutfDirect(logger.Yellow, " enum:\n") for _, enumValue := range v.Enum { - l.Outf(logger.Yellow, " - %s\n", enumValue) + l.OutfDirect(logger.Yellow, " - %s\n", enumValue) } } else { // Simple required variable - l.Outf(logger.Yellow, " - %s\n", v.Name) + l.OutfDirect(logger.Yellow, " - %s\n", v.Name) } } } diff --git a/internal/summary/summary_test.go b/internal/summary/summary_test.go index 6356b6ba4f..3c23dfc895 100644 --- a/internal/summary/summary_test.go +++ b/internal/summary/summary_test.go @@ -31,12 +31,12 @@ func TestPrintsDependenciesIfPresent(t *testing.T) { func createDummyLogger() (*bytes.Buffer, logger.Logger) { buffer := &bytes.Buffer{} - l := logger.Logger{ - Stderr: buffer, + l := logger.NewLogger(logger.LoggerOptions{ Stdout: buffer, + Stderr: buffer, Verbose: false, - } - return buffer, l + }) + return buffer, *l } func TestDoesNotPrintDependenciesIfMissing(t *testing.T) { diff --git a/setup.go b/setup.go index 47c3c7b7c1..4b33d7db19 100644 --- a/setup.go +++ b/setup.go @@ -182,7 +182,7 @@ func (e *Executor) setupStdFiles() { } func (e *Executor) setupLogger() { - e.Logger = &logger.Logger{ + e.Logger = logger.NewLogger(logger.LoggerOptions{ Stdin: e.Stdin, Stdout: e.Stdout, Stderr: e.Stderr, @@ -190,7 +190,8 @@ func (e *Executor) setupLogger() { Color: e.Color, AssumeYes: e.AssumeYes, AssumeTerm: e.AssumeTerm, - } + LogFormat: e.LogFormat, + }) } func (e *Executor) setupOutput() error { diff --git a/signals.go b/signals.go index 844466c1d2..ef1d0ab47d 100644 --- a/signals.go +++ b/signals.go @@ -22,7 +22,7 @@ func (e *Executor) InterceptInterruptSignals() { sig := <-ch if i+1 >= maxInterruptSignals { - e.Logger.Errf(logger.Red, "task: Signal received for the third time: %q. Forcing shutdown\n", sig) + e.Logger.Errorf("task: Signal received for the third time: %q. Forcing shutdown\n", sig) os.Exit(1) } diff --git a/task.go b/task.go index 489ef7e5dd..234378f8f3 100644 --- a/task.go +++ b/task.go @@ -1,12 +1,15 @@ package task import ( + "bytes" "context" "fmt" "os" "runtime" "slices" + "strings" "sync/atomic" + "time" "golang.org/x/sync/errgroup" "mvdan.cc/sh/v3/interp" @@ -15,6 +18,7 @@ import ( "github.com/go-task/task/v3/internal/env" "github.com/go-task/task/v3/internal/execext" "github.com/go-task/task/v3/internal/fingerprint" + "github.com/go-task/task/v3/internal/hash" "github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/internal/output" "github.com/go-task/task/v3/internal/slicesext" @@ -152,8 +156,22 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error { release := e.acquireConcurrencyLimit() defer release() - if err = e.startExecution(ctx, t, func(ctx context.Context) error { + if err = e.startExecution(ctx, t, func(ctx context.Context) (err error) { e.Logger.VerboseErrf(logger.Magenta, "task: %q started\n", call.Task) + + // Advanced logging. + hash := func() string { + h, _ := hash.Hash(t) + hp := strings.Split(h, ":") + return hp[len(hp)-1] + }() + start := time.Now() + e.Logger.Taskf("Task started", "task", call.Task, "action", "start", "hash", hash) + defer func() { + elapsed := time.Since(start).String() + e.Logger.Taskf("Task finished", "task", call.Task, "action", "finish", "hash", hash, "duration", elapsed, "error", err) + }() + if err := e.runDeps(ctx, t); err != nil { return err } @@ -205,7 +223,7 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error { } if err := e.mkdir(t); err != nil { - e.Logger.Errf(logger.Red, "task: cannot make directory %q: %v\n", t.Dir, err) + e.Logger.Errorf("task: cannot make directory %q: %v\n", t.Dir, err) } var deferredExitCode uint8 @@ -302,7 +320,7 @@ func (e *Executor) runDeferred(t *ast.Task, call *Call, i int, vars *ast.Vars, d } } -func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i int) error { +func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i int) (err error) { cmd := t.Cmds[i] switch { @@ -342,6 +360,27 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in } stdOut, stdErr, closer := outputWrapper.WrapWriter(e.Stdout, e.Stderr, t.Prefix, outputTemplater) + // Advanced logging. + hash := func() string { + h, _ := hash.Hash(t) + hp := strings.Split(h, ":") + return hp[len(hp)-1] + }() + start := time.Now() + e.Logger.Taskf("Command started", "task", t.Name(), "command", cmd.Cmd, "action", "start", "hash", hash) + defer func() { + var stdout bytes.Buffer + var stderr bytes.Buffer + elapsed := time.Since(start).String() + if s, ok := stdOut.(*output.LoggerWriter); ok { + stdout = s.Buffer + } + if s, ok := stdErr.(*output.LoggerWriter); ok { + stderr = s.Buffer + } + e.Logger.Taskf("Command finished", "task", t.Name(), "command", cmd.Cmd, "action", "finish", "hash", hash, "duration", elapsed, "error", err, "stdout", stdout.String(), "stderr", stderr.String()) + }() + err = execext.RunCommand(ctx, &execext.RunCommandOptions{ Command: cmd.Cmd, Dir: t.Dir, @@ -353,7 +392,7 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in Stderr: stdErr, }) if closeErr := closer(err); closeErr != nil { - e.Logger.Errf(logger.Red, "task: unable to close writer: %v\n", closeErr) + e.Logger.Errorf("task: unable to close writer: %v\n", closeErr) } var exitCode interp.ExitStatus if errors.As(err, &exitCode) && cmd.IgnoreError { diff --git a/watch.go b/watch.go index 8e7f7ccf7d..e95713c4db 100644 --- a/watch.go +++ b/watch.go @@ -41,7 +41,7 @@ func (e *Executor) watchTasks(calls ...*Call) error { if err == nil { e.Logger.Errf(logger.Green, "task: task \"%s\" finished running\n", c.Task) } else if !isContextError(err) { - e.Logger.Errf(logger.Red, "%v\n", err) + e.Logger.Errorf("%v\n", err) } }() } @@ -91,13 +91,13 @@ func (e *Executor) watchTasks(calls ...*Call) error { } t, err := e.GetTask(c) if err != nil { - e.Logger.Errf(logger.Red, "%v\n", err) + e.Logger.Errorf("%v\n", err) return } baseDir := filepathext.SmartJoin(e.Dir, t.Dir) files, err := e.collectSources(calls) if err != nil { - e.Logger.Errf(logger.Red, "%v\n", err) + e.Logger.Errorf("%v\n", err) return } @@ -110,7 +110,7 @@ func (e *Executor) watchTasks(calls ...*Call) error { if err == nil { e.Logger.Errf(logger.Green, "task: task \"%s\" finished running\n", c.Task) } else if !isContextError(err) { - e.Logger.Errf(logger.Red, "%v\n", err) + e.Logger.Errorf("%v\n", err) } }() } @@ -120,7 +120,7 @@ func (e *Executor) watchTasks(calls ...*Call) error { cancel() return default: - e.Logger.Errf(logger.Red, "%v\n", err) + e.Logger.Errorf("%v\n", err) } } } @@ -134,7 +134,7 @@ func (e *Executor) watchTasks(calls ...*Call) error { // from time to time. for { if err := e.registerWatchedDirs(w, calls...); err != nil { - e.Logger.Errf(logger.Red, "%v\n", err) + e.Logger.Errorf("%v\n", err) } time.Sleep(5 * time.Second) }