diff --git a/.taskrc.yml b/.taskrc.yml new file mode 100644 index 0000000000..6d265d6f2c --- /dev/null +++ b/.taskrc.yml @@ -0,0 +1,4 @@ +experiments: + GENTLE_FORCE: 0 + REMOTE_TASKFILES: 0 + ENV_PRECEDENCE: 0 diff --git a/args/args.go b/args/args.go index 96c1aa3df2..0e9eaab95f 100644 --- a/args/args.go +++ b/args/args.go @@ -3,10 +3,36 @@ package args import ( "strings" + "github.com/spf13/pflag" + "mvdan.cc/sh/v3/syntax" + "github.com/go-task/task/v3" "github.com/go-task/task/v3/taskfile/ast" ) +// Get fetches the remaining arguments after CLI parsing and splits them into +// two groups: the arguments before the double dash (--) and the arguments after +// the double dash. +func Get() ([]string, []string, error) { + args := pflag.Args() + doubleDashPos := pflag.CommandLine.ArgsLenAtDash() + + if doubleDashPos == -1 { + return args, nil, nil + } + + var quotedCliArgs []string + for _, arg := range args[doubleDashPos:] { + quotedCliArg, err := syntax.Quote(arg, syntax.LangBash) + if err != nil { + return nil, nil, err + } + quotedCliArgs = append(quotedCliArgs, quotedCliArg) + } + + return args[:doubleDashPos], quotedCliArgs, nil +} + // Parse parses command line argument: tasks and global variables func Parse(args ...string) ([]*task.Call, *ast.Vars) { calls := []*task.Call{} diff --git a/cmd/task/task.go b/cmd/task/task.go index dcad9ca187..2090a2b505 100644 --- a/cmd/task/task.go +++ b/cmd/task/task.go @@ -5,10 +5,8 @@ import ( "fmt" "os" "path/filepath" - "strings" "github.com/spf13/pflag" - "mvdan.cc/sh/v3/syntax" "github.com/go-task/task/v3" "github.com/go-task/task/v3/args" @@ -75,7 +73,7 @@ func run() error { if err != nil { return err } - args, _, err := getArgs() + _, args, err := args.Get() if err != nil { return err } @@ -155,17 +153,12 @@ func run() error { return nil } - var ( - calls []*task.Call - globals *ast.Vars - ) - - tasksAndVars, cliArgs, err := getArgs() + // Parse the remaining arguments + argv, cliArgs, err := args.Get() if err != nil { return err } - - calls, globals = args.Parse(tasksAndVars...) + calls, globals := args.Parse(argv...) // If there are no calls, run the default task instead if len(calls) == 0 { @@ -191,24 +184,3 @@ func run() error { return e.Run(ctx, calls...) } - -func getArgs() ([]string, string, error) { - var ( - args = pflag.Args() - doubleDashPos = pflag.CommandLine.ArgsLenAtDash() - ) - - if doubleDashPos == -1 { - return args, "", nil - } - - var quotedCliArgs []string - for _, arg := range args[doubleDashPos:] { - quotedCliArg, err := syntax.Quote(arg, syntax.LangBash) - if err != nil { - return nil, "", err - } - quotedCliArgs = append(quotedCliArgs, quotedCliArg) - } - return args[:doubleDashPos], strings.Join(quotedCliArgs, " "), nil -} diff --git a/errors/errors.go b/errors/errors.go index ea0216a7ff..ea3d7ce024 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -8,6 +8,11 @@ const ( CodeUnknown // Used when no other exit code is appropriate ) +// TaskRC related exit codes +const ( + CodeTaskRCNotFoundError int = iota + 50 +) + // Taskfile related exit codes const ( CodeTaskfileNotFound int = iota + 100 diff --git a/errors/errors_taskrc.go b/errors/errors_taskrc.go new file mode 100644 index 0000000000..26a22683fe --- /dev/null +++ b/errors/errors_taskrc.go @@ -0,0 +1,20 @@ +package errors + +import "fmt" + +type TaskRCNotFoundError struct { + URI string + Walk bool +} + +func (err TaskRCNotFoundError) Error() string { + var walkText string + if err.Walk { + walkText = " (or any of the parent directories)" + } + return fmt.Sprintf(`task: No Task config file found at %q%s`, err.URI, walkText) +} + +func (err TaskRCNotFoundError) Code() int { + return CodeTaskRCNotFoundError +} diff --git a/internal/experiments/experiment.go b/internal/experiments/experiment.go index 07bd22aad8..2546b0a981 100644 --- a/internal/experiments/experiment.go +++ b/internal/experiments/experiment.go @@ -4,6 +4,8 @@ import ( "fmt" "slices" "strconv" + + "github.com/go-task/task/v3/taskrc/ast" ) type Experiment struct { @@ -14,8 +16,11 @@ type Experiment struct { // New creates a new experiment with the given name and sets the values that can // enable it. -func New(xName string, allowedValues ...int) Experiment { - value := experimentConfig.Experiments[xName] +func New(xName string, config *ast.TaskRC, allowedValues ...int) Experiment { + var value int + if config != nil { + value = config.Experiments[xName] + } if value == 0 { value, _ = strconv.Atoi(getEnv(xName)) diff --git a/internal/experiments/experiment_test.go b/internal/experiments/experiment_test.go index f953f92f97..632d8e02fd 100644 --- a/internal/experiments/experiment_test.go +++ b/internal/experiments/experiment_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/go-task/task/v3/internal/experiments" + "github.com/go-task/task/v3/taskrc/ast" ) func TestNew(t *testing.T) { @@ -16,43 +17,47 @@ func TestNew(t *testing.T) { ) tests := []struct { name string + config *ast.TaskRC allowedValues []int - value int + env int wantEnabled bool wantActive bool wantValid error + wantValue int }{ { - name: `[] allowed, value=""`, + name: `[] allowed, env=""`, wantEnabled: false, wantActive: false, }, { - name: `[] allowed, value="1"`, - value: 1, + name: `[] allowed, env="1"`, + env: 1, wantEnabled: false, wantActive: false, wantValid: &experiments.InactiveError{ Name: exampleExperiment, }, + wantValue: 1, }, { - name: `[1] allowed, value=""`, + name: `[1] allowed, env=""`, allowedValues: []int{1}, wantEnabled: false, wantActive: true, }, { - name: `[1] allowed, value="1"`, + name: `[1] allowed, env="1"`, allowedValues: []int{1}, - value: 1, + env: 1, wantEnabled: true, wantActive: true, + wantValue: 1, }, { - name: `[1] allowed, value="2"`, + name: `[1] allowed, env="2"`, allowedValues: []int{1}, - value: 2, + env: 2, wantEnabled: false, wantActive: true, wantValid: &experiments.InvalidValueError{ @@ -60,16 +65,76 @@ func TestNew(t *testing.T) { AllowedValues: []int{1}, Value: 2, }, + wantValue: 2, + }, + { + name: `[1, 2] allowed, env="1"`, + allowedValues: []int{1, 2}, + env: 1, + wantEnabled: true, + wantActive: true, + wantValue: 1, + }, + { + name: `[1, 2] allowed, env="1"`, + allowedValues: []int{1, 2}, + env: 2, + wantEnabled: true, + wantActive: true, + wantValue: 2, + }, + { + name: `[1] allowed, config="1"`, + config: &ast.TaskRC{ + Experiments: map[string]int{ + exampleExperiment: 1, + }, + }, + allowedValues: []int{1}, + wantEnabled: true, + wantActive: true, + wantValue: 1, + }, + { + name: `[1] allowed, config="2"`, + config: &ast.TaskRC{ + Experiments: map[string]int{ + exampleExperiment: 2, + }, + }, + allowedValues: []int{1}, + wantEnabled: false, + wantActive: true, + wantValid: &experiments.InvalidValueError{ + Name: exampleExperiment, + AllowedValues: []int{1}, + Value: 2, + }, + wantValue: 2, + }, + { + name: `[1, 2] allowed, env="1", config="2"`, + config: &ast.TaskRC{ + Experiments: map[string]int{ + exampleExperiment: 2, + }, + }, + allowedValues: []int{1, 2}, + env: 1, + wantEnabled: true, + wantActive: true, + wantValue: 2, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - t.Setenv(exampleExperimentEnv, strconv.Itoa(tt.value)) - x := experiments.New(exampleExperiment, tt.allowedValues...) + t.Setenv(exampleExperimentEnv, strconv.Itoa(tt.env)) + x := experiments.New(exampleExperiment, tt.config, tt.allowedValues...) assert.Equal(t, exampleExperiment, x.Name) assert.Equal(t, tt.wantEnabled, x.Enabled()) assert.Equal(t, tt.wantActive, x.Active()) assert.Equal(t, tt.wantValid, x.Valid()) + assert.Equal(t, tt.wantValue, x.Value) }) } } diff --git a/internal/experiments/experiments.go b/internal/experiments/experiments.go index ab676022f0..9e646c57b7 100644 --- a/internal/experiments/experiments.go +++ b/internal/experiments/experiments.go @@ -6,46 +6,47 @@ import ( "path/filepath" "strings" - "github.com/Masterminds/semver/v3" "github.com/joho/godotenv" - "github.com/spf13/pflag" - "gopkg.in/yaml.v3" + + "github.com/go-task/task/v3/taskrc" ) const envPrefix = "TASK_X_" -var defaultConfigFilenames = []string{ - ".taskrc.yml", - ".taskrc.yaml", -} - -type experimentConfigFile struct { - Experiments map[string]int `yaml:"experiments"` - Version *semver.Version -} - +// Active experiments. var ( GentleForce Experiment RemoteTaskfiles Experiment - AnyVariables Experiment - MapVariables Experiment EnvPrecedence Experiment ) -// An internal list of all the initialized experiments used for iterating. +// Inactive experiments. These are experiments that cannot be enabled, but are +// preserved for error handling. var ( - xList []Experiment - experimentConfig experimentConfigFile + AnyVariables Experiment + MapVariables Experiment ) -func init() { - readDotEnv() - experimentConfig = readConfig() - GentleForce = New("GENTLE_FORCE", 1) - RemoteTaskfiles = New("REMOTE_TASKFILES", 1) - AnyVariables = New("ANY_VARIABLES") - MapVariables = New("MAP_VARIABLES") - EnvPrecedence = New("ENV_PRECEDENCE", 1) +// An internal list of all the initialized experiments used for iterating. +var xList []Experiment + +func Parse(dir string) { + // Read any .env files + readDotEnv(dir) + + // Create a node for the Task config reader + node, _ := taskrc.NewNode("", dir) + + // Read the Task config file + reader := taskrc.NewReader() + config, _ := reader.Read(node) + + // Initialize the experiments + GentleForce = New("GENTLE_FORCE", config, 1) + RemoteTaskfiles = New("REMOTE_TASKFILES", config, 1) + EnvPrecedence = New("ENV_PRECEDENCE", config, 1) + AnyVariables = New("ANY_VARIABLES", config) + MapVariables = New("MAP_VARIABLES", config) } // Validate checks if any experiments have been enabled while being inactive. @@ -68,29 +69,19 @@ func getEnv(xName string) string { return os.Getenv(envName) } -func getFilePath(filename string) string { - // Parse the CLI flags again to get the directory/taskfile being run - // We use a flagset here so that we can parse a subset of flags without exiting on error. - var dir, taskfile string - fs := pflag.NewFlagSet("experiments", pflag.ContinueOnError) - fs.StringVarP(&dir, "dir", "d", "", "Sets directory of execution.") - fs.StringVarP(&taskfile, "taskfile", "t", "", `Choose which Taskfile to run. Defaults to "Taskfile.yml".`) - fs.Usage = func() {} - _ = fs.Parse(os.Args[1:]) - // If the directory is set, find a .env file in that directory. +func getFilePath(filename, dir string) string { if dir != "" { return filepath.Join(dir, filename) } - // If the taskfile is set, find a .env file in the directory containing the Taskfile. - if taskfile != "" { - return filepath.Join(filepath.Dir(taskfile), filename) - } - // Otherwise just use the current working directory. return filename } -func readDotEnv() { - env, _ := godotenv.Read(getFilePath(".env")) +func readDotEnv(dir string) { + env, err := godotenv.Read(getFilePath(".env", dir)) + if err != nil { + return + } + // If the env var is an experiment, set it. for key, value := range env { if strings.HasPrefix(key, envPrefix) { @@ -98,27 +89,3 @@ func readDotEnv() { } } } - -func readConfig() experimentConfigFile { - var cfg experimentConfigFile - - var content []byte - var err error - for _, filename := range defaultConfigFilenames { - path := getFilePath(filename) - content, err = os.ReadFile(path) - if err == nil { - break - } - } - - if err != nil { - return experimentConfigFile{} - } - - if err := yaml.Unmarshal(content, &cfg); err != nil { - return experimentConfigFile{} - } - - return cfg -} diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 1c981f8b8f..26d90346ff 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -4,6 +4,7 @@ import ( "cmp" "log" "os" + "path/filepath" "strconv" "time" @@ -76,6 +77,26 @@ var ( ) func init() { + // Config files can enable experiments which alter the availability and/or + // behavior of some flags, so we need to parse the experiments before the + // flags. However, we need the --taskfile and --dir flags before we can + // parse the experiments as they can alter the location of the config files. + // Because of this circular dependency, we parse the flags twice. First, we + // get the --taskfile and --dir flags, then we parse the experiments, then + // we parse the flags again to get the full set. We use a flagset here so + // that we can parse a subset of flags without exiting on error. + var dir, entrypoint string + fs := pflag.NewFlagSet("experiments", pflag.ContinueOnError) + fs.StringVarP(&dir, "dir", "d", "", "") + fs.StringVarP(&entrypoint, "taskfile", "t", "", "") + fs.Usage = func() {} + _ = fs.Parse(os.Args[1:]) + + // Parse the experiments + dir = cmp.Or(dir, filepath.Dir(entrypoint)) + experiments.Parse(dir) + + // Parse the rest of the flags log.SetFlags(0) log.SetOutput(os.Stderr) pflag.Usage = func() { diff --git a/internal/fsext/fs.go b/internal/fsext/fs.go new file mode 100644 index 0000000000..5a64642e0e --- /dev/null +++ b/internal/fsext/fs.go @@ -0,0 +1,146 @@ +package fsext + +import ( + "os" + "path/filepath" + + "github.com/go-task/task/v3/internal/filepathext" + "github.com/go-task/task/v3/internal/sysinfo" +) + +// DefaultDir will return the default directory given an entrypoint or +// directory. If the directory is set, it will ensure it is an absolute path and +// return it. If the entrypoint is set, but the directory is not, it will leave +// the directory blank. If both are empty, it will default the directory to the +// current working directory. +func DefaultDir(entrypoint, dir string) string { + // If the directory is set, ensure it is an absolute path + if dir != "" { + var err error + dir, err = filepath.Abs(dir) + if err != nil { + return "" + } + return dir + } + + // If the entrypoint and dir are empty, we default the directory to the current working directory + if entrypoint == "" { + wd, err := os.Getwd() + if err != nil { + return "" + } + return wd + } + + // If the entrypoint is set, but the directory is not, we leave the directory blank + return "" +} + +// Search will look for files with the given possible filenames using the given +// entrypoint and directory. If the entrypoint is set, it will check if the +// entrypoint matches a file or if it matches a directory containing one of the +// possible filenames. Otherwise, it will walk up the file tree starting at the +// given directory and perform a search in each directory for the possible +// filenames until it finds a match or reaches the root directory. If the +// entrypoint and directory are both empty, it will default the directory to the +// current working directory and perform a recursive search starting there. If a +// match is found, the absolute path to the file will be returned with its +// directory. If no match is found, an error will be returned. +func Search(entrypoint, dir string, possibleFilenames []string) (string, string, error) { + var err error + if entrypoint != "" { + entrypoint, err = SearchPath(entrypoint, possibleFilenames) + if err != nil { + return "", "", err + } + if dir == "" { + dir = filepath.Dir(entrypoint) + } else { + dir, err = filepath.Abs(dir) + if err != nil { + return "", "", err + } + } + return entrypoint, dir, nil + } + if dir == "" { + dir, err = os.Getwd() + if err != nil { + return "", "", err + } + } + entrypoint, err = SearchPathRecursively(dir, possibleFilenames) + if err != nil { + return "", "", err + } + dir = filepath.Dir(entrypoint) + return entrypoint, dir, nil +} + +// Search will check if a file at the given path exists or not. If it does, it +// will return the path to it. If it does not, it will search for any files at +// the given path with any of the given possible names. If any of these match a +// file, the first matching path will be returned. If no files are found, an +// error will be returned. +func SearchPath(path string, possibleFilenames []string) (string, error) { + // Get file info about the path + fi, err := os.Stat(path) + if err != nil { + return "", err + } + + // If the path exists and is a regular file, device, symlink, or named pipe, + // return the absolute path to it + if fi.Mode().IsRegular() || + fi.Mode()&os.ModeDevice != 0 || + fi.Mode()&os.ModeSymlink != 0 || + fi.Mode()&os.ModeNamedPipe != 0 { + return filepath.Abs(path) + } + + // If the path is a directory, check if any of the possible names exist + // in that directory + for _, filename := range possibleFilenames { + alt := filepathext.SmartJoin(path, filename) + if _, err := os.Stat(alt); err == nil { + return filepath.Abs(alt) + } + } + + return "", os.ErrNotExist +} + +// SearchRecursively will check if a file at the given path exists by calling +// the exists function. If a file is not found, it will walk up the directory +// tree calling the Search function until it finds a file or reaches the root +// directory. On supported operating systems, it will also check if the user ID +// of the directory changes and abort if it does. +func SearchPathRecursively(path string, possibleFilenames []string) (string, error) { + owner, err := sysinfo.Owner(path) + if err != nil { + return "", err + } + for { + fpath, err := SearchPath(path, possibleFilenames) + if err == nil { + return fpath, nil + } + + // Get the parent path/user id + parentPath := filepath.Dir(path) + parentOwner, err := sysinfo.Owner(parentPath) + if err != nil { + return "", err + } + + // Error if we reached the root directory and still haven't found a file + // OR if the user id of the directory changes + if path == parentPath || (parentOwner != owner) { + return "", os.ErrNotExist + } + + owner = parentOwner + path = parentPath + } +} diff --git a/internal/fsext/fs_test.go b/internal/fsext/fs_test.go new file mode 100644 index 0000000000..1ad5624de7 --- /dev/null +++ b/internal/fsext/fs_test.go @@ -0,0 +1,152 @@ +package fsext + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDefaultDir(t *testing.T) { + t.Parallel() + + wd, err := os.Getwd() + require.NoError(t, err) + + tests := []struct { + name string + entrypoint string + dir string + expected string + }{ + { + name: "default to current working directory", + entrypoint: "", + dir: "", + expected: wd, + }, + { + name: "resolves relative dir path", + entrypoint: "", + dir: "./dir", + expected: filepath.Join(wd, "dir"), + }, + { + name: "return entrypoint if set", + entrypoint: filepath.Join(wd, "entrypoint"), + dir: "", + expected: "", + }, + { + name: "if entrypoint and dir are set", + entrypoint: filepath.Join(wd, "entrypoint"), + dir: filepath.Join(wd, "dir"), + expected: filepath.Join(wd, "dir"), + }, + { + name: "if entrypoint and dir are set and dir is relative", + entrypoint: filepath.Join(wd, "entrypoint"), + dir: "./dir", + expected: filepath.Join(wd, "dir"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.expected, DefaultDir(tt.entrypoint, tt.dir)) + }) + } +} + +func TestSearch(t *testing.T) { + t.Parallel() + + wd, err := os.Getwd() + require.NoError(t, err) + + tests := []struct { + name string + entrypoint string + dir string + possibleFilenames []string + expectedEntrypoint string + expectedDir string + }{ + { + name: "find foo.txt using relative entrypoint", + entrypoint: "./testdata/foo.txt", + possibleFilenames: []string{"foo.txt"}, + expectedEntrypoint: filepath.Join(wd, "testdata", "foo.txt"), + expectedDir: filepath.Join(wd, "testdata"), + }, + { + name: "find foo.txt using absolute entrypoint", + entrypoint: filepath.Join(wd, "testdata", "foo.txt"), + possibleFilenames: []string{"foo.txt"}, + expectedEntrypoint: filepath.Join(wd, "testdata", "foo.txt"), + expectedDir: filepath.Join(wd, "testdata"), + }, + { + name: "find foo.txt using relative dir", + dir: "./testdata", + possibleFilenames: []string{"foo.txt"}, + expectedEntrypoint: filepath.Join(wd, "testdata", "foo.txt"), + expectedDir: filepath.Join(wd, "testdata"), + }, + { + name: "find foo.txt using absolute dir", + dir: filepath.Join(wd, "testdata"), + possibleFilenames: []string{"foo.txt"}, + expectedEntrypoint: filepath.Join(wd, "testdata", "foo.txt"), + expectedDir: filepath.Join(wd, "testdata"), + }, + { + name: "find foo.txt using relative dir and relative entrypoint", + entrypoint: "./testdata/foo.txt", + dir: "./testdata/some/other/dir", + possibleFilenames: []string{"foo.txt"}, + expectedEntrypoint: filepath.Join(wd, "testdata", "foo.txt"), + expectedDir: filepath.Join(wd, "testdata", "some", "other", "dir"), + }, + { + name: "find fs.go using no entrypoint or dir", + entrypoint: "", + dir: "", + possibleFilenames: []string{"fs.go"}, + expectedEntrypoint: filepath.Join(wd, "fs.go"), + expectedDir: wd, + }, + { + name: "find ../../Taskfile.yml using no entrypoint or dir by walking", + entrypoint: "", + dir: "", + possibleFilenames: []string{"Taskfile.yml"}, + expectedEntrypoint: filepath.Join(wd, "..", "..", "Taskfile.yml"), + expectedDir: filepath.Join(wd, "..", ".."), + }, + { + name: "find foo.txt first if listed first in possible filenames", + entrypoint: "./testdata", + possibleFilenames: []string{"foo.txt", "bar.txt"}, + expectedEntrypoint: filepath.Join(wd, "testdata", "foo.txt"), + expectedDir: filepath.Join(wd, "testdata"), + }, + { + name: "find bar.txt first if listed first in possible filenames", + entrypoint: "./testdata", + possibleFilenames: []string{"bar.txt", "foo.txt"}, + expectedEntrypoint: filepath.Join(wd, "testdata", "bar.txt"), + expectedDir: filepath.Join(wd, "testdata"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + entrypoint, dir, err := Search(tt.entrypoint, tt.dir, tt.possibleFilenames) + require.NoError(t, err) + require.Equal(t, tt.expectedEntrypoint, entrypoint) + require.Equal(t, tt.expectedDir, dir) + }) + } +} diff --git a/internal/fsext/testdata/bar.txt b/internal/fsext/testdata/bar.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/internal/fsext/testdata/foo.txt b/internal/fsext/testdata/foo.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/taskfile/node.go b/taskfile/node.go index 486a0a16f0..8343b6d8bf 100644 --- a/taskfile/node.go +++ b/taskfile/node.go @@ -2,8 +2,6 @@ package taskfile import ( "context" - "os" - "path/filepath" "strings" "time" @@ -11,6 +9,7 @@ import ( "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/internal/experiments" + "github.com/go-task/task/v3/internal/fsext" ) type Node interface { @@ -30,7 +29,7 @@ func NewRootNode( insecure bool, timeout time.Duration, ) (Node, error) { - dir = getDefaultDir(entrypoint, dir) + dir = fsext.DefaultDir(entrypoint, dir) // If the entrypoint is "-", we read from stdin if entrypoint == "-" { return NewStdinNode(dir) @@ -81,26 +80,3 @@ func getScheme(uri string) (string, error) { } return "", nil } - -func getDefaultDir(entrypoint, dir string) string { - // If the entrypoint and dir are empty, we default the directory to the current working directory - if dir == "" { - if entrypoint == "" { - wd, err := os.Getwd() - if err != nil { - return "" - } - dir = wd - } - return dir - } - - // If the directory is set, ensure it is an absolute path - var err error - dir, err = filepath.Abs(dir) - if err != nil { - return "" - } - - return dir -} diff --git a/taskfile/node_file.go b/taskfile/node_file.go index 99e2ec9cc2..7d82f17eed 100644 --- a/taskfile/node_file.go +++ b/taskfile/node_file.go @@ -9,6 +9,7 @@ import ( "github.com/go-task/task/v3/internal/execext" "github.com/go-task/task/v3/internal/filepathext" + "github.com/go-task/task/v3/internal/fsext" ) // A FileNode is a node that reads a taskfile from the local filesystem. @@ -20,7 +21,7 @@ type FileNode struct { func NewFileNode(entrypoint, dir string, opts ...NodeOption) (*FileNode, error) { var err error base := NewBaseNode(dir, opts...) - entrypoint, base.dir, err = resolveFileNodeEntrypointAndDir(entrypoint, base.dir) + entrypoint, base.dir, err = fsext.Search(entrypoint, base.dir, defaultTaskfiles) if err != nil { return nil, err } @@ -47,34 +48,6 @@ func (node *FileNode) Read(ctx context.Context) ([]byte, error) { return io.ReadAll(f) } -// resolveFileNodeEntrypointAndDir resolves checks the values of entrypoint and dir and -// populates them with default values if necessary. -func resolveFileNodeEntrypointAndDir(entrypoint, dir string) (string, string, error) { - var err error - if entrypoint != "" { - entrypoint, err = Exists(entrypoint) - if err != nil { - return "", "", err - } - if dir == "" { - dir = filepath.Dir(entrypoint) - } - return entrypoint, dir, nil - } - if dir == "" { - dir, err = os.Getwd() - if err != nil { - return "", "", err - } - } - entrypoint, err = ExistsWalk(dir) - if err != nil { - return "", "", err - } - dir = filepath.Dir(entrypoint) - return entrypoint, dir, nil -} - func (node *FileNode) ResolveEntrypoint(entrypoint string) (string, error) { // If the file is remote, we don't need to resolve the path if strings.Contains(entrypoint, "://") { diff --git a/taskfile/taskfile.go b/taskfile/taskfile.go index 3502638a16..47744c5933 100644 --- a/taskfile/taskfile.go +++ b/taskfile/taskfile.go @@ -4,15 +4,11 @@ import ( "context" "net/http" "net/url" - "os" - "path/filepath" "slices" "strings" "time" "github.com/go-task/task/v3/errors" - "github.com/go-task/task/v3/internal/filepathext" - "github.com/go-task/task/v3/internal/sysinfo" ) var ( @@ -94,65 +90,3 @@ func RemoteExists(ctx context.Context, u *url.URL, timeout time.Duration) (*url. return nil, errors.TaskfileNotFoundError{URI: u.String(), Walk: false} } - -// Exists will check if a file at the given path Exists. If it does, it will -// return the path to it. If it does not, it will search for any files at the -// given path with any of the default Taskfile files names. If any of these -// match a file, the first matching path will be returned. If no files are -// found, an error will be returned. -func Exists(path string) (string, error) { - fi, err := os.Stat(path) - if err != nil { - return "", err - } - if fi.Mode().IsRegular() || - fi.Mode()&os.ModeDevice != 0 || - fi.Mode()&os.ModeSymlink != 0 || - fi.Mode()&os.ModeNamedPipe != 0 { - return filepath.Abs(path) - } - - for _, taskfile := range defaultTaskfiles { - alt := filepathext.SmartJoin(path, taskfile) - if _, err := os.Stat(alt); err == nil { - return filepath.Abs(alt) - } - } - - return "", errors.TaskfileNotFoundError{URI: path, Walk: false} -} - -// ExistsWalk will check if a file at the given path exists by calling the -// exists function. If a file is not found, it will walk up the directory tree -// calling the exists function until it finds a file or reaches the root -// directory. On supported operating systems, it will also check if the user ID -// of the directory changes and abort if it does. -func ExistsWalk(path string) (string, error) { - origPath := path - owner, err := sysinfo.Owner(path) - if err != nil { - return "", err - } - for { - fpath, err := Exists(path) - if err == nil { - return fpath, nil - } - - // Get the parent path/user id - parentPath := filepath.Dir(path) - parentOwner, err := sysinfo.Owner(parentPath) - if err != nil { - return "", err - } - - // Error if we reached the root directory and still haven't found a file - // OR if the user id of the directory changes - if path == parentPath || (parentOwner != owner) { - return "", errors.TaskfileNotFoundError{URI: origPath, Walk: false} - } - - owner = parentOwner - path = parentPath - } -} diff --git a/taskrc/ast/taskrc.go b/taskrc/ast/taskrc.go new file mode 100644 index 0000000000..f82452a94d --- /dev/null +++ b/taskrc/ast/taskrc.go @@ -0,0 +1,8 @@ +package ast + +import "github.com/Masterminds/semver/v3" + +type TaskRC struct { + Version *semver.Version `yaml:"version"` + Experiments map[string]int `yaml:"experiments"` +} diff --git a/taskrc/node.go b/taskrc/node.go new file mode 100644 index 0000000000..4aef0aa84a --- /dev/null +++ b/taskrc/node.go @@ -0,0 +1,24 @@ +package taskrc + +import "github.com/go-task/task/v3/internal/fsext" + +type Node struct { + entrypoint string + dir string +} + +func NewNode( + entrypoint string, + dir string, +) (*Node, error) { + dir = fsext.DefaultDir(entrypoint, dir) + var err error + entrypoint, dir, err = fsext.Search(entrypoint, dir, defaultTaskRCs) + if err != nil { + return nil, err + } + return &Node{ + entrypoint: entrypoint, + dir: dir, + }, nil +} diff --git a/taskrc/reader.go b/taskrc/reader.go new file mode 100644 index 0000000000..aad26eef00 --- /dev/null +++ b/taskrc/reader.go @@ -0,0 +1,79 @@ +package taskrc + +import ( + "os" + + "gopkg.in/yaml.v3" + + "github.com/go-task/task/v3/taskrc/ast" +) + +type ( + // DebugFunc is a function that can be called to log debug messages. + DebugFunc func(string) + // A ReaderOption is any type that can apply a configuration to a [Reader]. + ReaderOption interface { + ApplyToReader(*Reader) + } + // A Reader will recursively read Taskfiles from a given [Node] and build a + // [ast.TaskRC] from them. + Reader struct { + debugFunc DebugFunc + } +) + +// NewReader constructs a new Taskfile [Reader] using the given Node and +// options. +func NewReader(opts ...ReaderOption) *Reader { + r := &Reader{ + debugFunc: nil, + } + r.Options(opts...) + return r +} + +// Options loops through the given [ReaderOption] functions and applies them to +// the [Reader]. +func (r *Reader) Options(opts ...ReaderOption) { + for _, opt := range opts { + opt.ApplyToReader(r) + } +} + +// WithDebugFunc sets the debug function to be used by the [Reader]. If set, +// this function will be called with debug messages. This can be useful if the +// caller wants to log debug messages from the [Reader]. By default, no debug +// function is set and the logs are not written. +func WithDebugFunc(debugFunc DebugFunc) ReaderOption { + return &debugFuncOption{debugFunc: debugFunc} +} + +type debugFuncOption struct { + debugFunc DebugFunc +} + +func (o *debugFuncOption) ApplyToReader(r *Reader) { + r.debugFunc = o.debugFunc +} + +// Read will read the Task config defined by the [Reader]'s [Node]. +func (r *Reader) Read(node *Node) (*ast.TaskRC, error) { + var config ast.TaskRC + + if node == nil { + return nil, os.ErrInvalid + } + + // Read the file + b, err := os.ReadFile(node.entrypoint) + if err != nil { + return nil, err + } + + // Parse the content + if err := yaml.Unmarshal(b, &config); err != nil { + return nil, err + } + + return &config, nil +} diff --git a/taskrc/taskrc.go b/taskrc/taskrc.go new file mode 100644 index 0000000000..af99305553 --- /dev/null +++ b/taskrc/taskrc.go @@ -0,0 +1,6 @@ +package taskrc + +var defaultTaskRCs = []string{ + ".taskrc.yml", + ".taskrc.yaml", +}