Private/Model.ps1
|
using namespace System.Collections using namespace System.Collections.Generic using namespace System.Collections.Specialized using namespace System.Globalization # Errors, path helpers and the Taskfile reader (YAML -> model, includes merged). # # The model is made of plain PSCustomObjects / dictionaries / lists on purpose: it is shared # between the runspaces of parallel tasks, so it must not contain script blocks or class # instances that are bound to a runspace. # # NOTE: a [string] typed value can never be $null in PowerShell (it becomes ''), so "not # found" is always tested with [string]::IsNullOrEmpty(). [NoRunspaceAffinity()] class TaskError { # Exit codes follow go-task (https://taskfile.dev/docs/reference/cli#exit-codes). static [int] $Unknown = 1 static [int] $TaskfileNotFound = 100 static [int] $TaskfileAlreadyExists = 101 static [int] $TaskfileDecode = 102 static [int] $TaskfileVersionCheck = 107 static [int] $TaskfileInvalid = 109 static [int] $TaskfileCycle = 110 static [int] $TaskNotFound = 200 static [int] $TaskRun = 201 static [int] $TaskInternal = 202 static [int] $TaskNameConflict = 203 static [int] $TaskCalledTooManyTimes = 204 static [int] $TaskCancelled = 205 static [int] $TaskMissingRequiredVars = 206 static [int] $TaskNotAllowedVars = 207 static [Exception] Create([string] $message, [int] $code) { $exception = [System.InvalidOperationException]::new($message) $exception.Data['TaskCode'] = $code return $exception } static [Exception] Create([string] $message, [int] $code, [Exception] $inner) { $exception = [System.InvalidOperationException]::new($message, $inner) $exception.Data['TaskCode'] = $code return $exception } # Finds the exception created by Create() inside wrappers added by the PowerShell engine. static [Exception] Find([Exception] $exception) { $current = $exception while ($null -ne $current) { if ($current.Data.Contains('TaskCode')) { return $current } $current = $current.InnerException } return $null } static [int] CodeOf([Exception] $exception) { $found = [TaskError]::Find($exception) if ($null -ne $found) { return [int]$found.Data['TaskCode'] } return [TaskError]::Unknown } # The innermost meaningful exception (strips method-invocation wrappers). static [Exception] Unwrap([Exception] $exception) { $found = [TaskError]::Find($exception) if ($null -ne $found) { return $found } $current = $exception while ($null -ne $current.InnerException -and ($current -is [System.Management.Automation.MethodInvocationException] -or $current -is [System.Management.Automation.ActionPreferenceStopException] -or $current -is [System.Reflection.TargetInvocationException])) { $current = $current.InnerException } return $current } } [NoRunspaceAffinity()] class TaskPath { hidden static [string[]] $KnownAbsoluteVars = @('.ROOT_DIR', '.TASKFILE_DIR', '.USER_WORKING_DIR') # The home directory, taken from the environment like Go's os.UserHomeDir(). static [string] Home() { $variable = if ($global:IsWindows) { 'USERPROFILE' } else { 'HOME' } $value = [Environment]::GetEnvironmentVariable($variable) if ([string]::IsNullOrEmpty($value)) { return [Environment]::GetFolderPath('UserProfile') } return $value } static [string] ExpandHome([string] $path) { if ($path -eq '~') { return [TaskPath]::Home() } if ($path.StartsWith('~/') -or $path.StartsWith('~\')) { return [System.IO.Path]::Combine([TaskPath]::Home(), $path.Substring(2)) } return $path } # Remote Taskfiles (an experiment of go-task) are not implemented. static [void] AssertLocal([string] $path) { if ($path -match '^(https?|git|ssh)://' -or $path -match '^git@') { throw [TaskError]::Create("task: Remote Taskfiles are not supported by PS-TaskFile: $path", [TaskError]::TaskfileInvalid) } } # True for absolute paths and for templates that will expand to an absolute path. static [bool] IsAbsolute([string] $path) { foreach ($name in [TaskPath]::KnownAbsoluteVars) { if ($path.Contains($name)) { return $true } } return [System.IO.Path]::IsPathFullyQualified($path) } # Joins $path onto $base unless $path is already absolute. static [string] SmartJoin([string] $base, [string] $path) { if ([string]::IsNullOrEmpty($path)) { return $base } if ([TaskPath]::IsAbsolute($path)) { return $path } if ([string]::IsNullOrEmpty($base)) { return $path } return [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($base, $path)) } static [string] Full([string] $base, [string] $path) { $expanded = [TaskPath]::ExpandHome($path) if ([System.IO.Path]::IsPathFullyQualified($expanded)) { return [System.IO.Path]::GetFullPath($expanded) } return [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($base, $expanded)) } } [NoRunspaceAffinity()] class TaskfileReader { static [string[]] $DefaultNames = @( 'Taskfile.yml', 'taskfile.yml', 'Taskfile.yaml', 'taskfile.yaml', 'Taskfile.dist.yml', 'taskfile.dist.yml', 'Taskfile.dist.yaml', 'taskfile.dist.yaml' ) hidden static [string[]] $KnownOS = @('aix', 'android', 'darwin', 'dragonfly', 'freebsd', 'illumos', 'ios', 'js', 'linux', 'netbsd', 'openbsd', 'plan9', 'solaris', 'wasip1', 'windows') hidden static [string[]] $KnownArch = @('386', 'amd64', 'arm', 'arm64', 'loong64', 'mips', 'mips64', 'mips64le', 'mipsle', 'ppc64', 'ppc64le', 'riscv64', 's390x', 'wasm') # ---- locating the Taskfile -------------------------------------------------------------- # Returns the path of the Taskfile inside $directory, or '' when there is none. static [string] FindInDirectory([string] $directory) { foreach ($name in [TaskfileReader]::DefaultNames) { $candidate = [System.IO.Path]::Combine($directory, $name) if ([System.IO.File]::Exists($candidate)) { # Report the name as it is stored on disk (case-insensitive file systems). $actual = [System.IO.Directory]::GetFiles($directory, $name) if ($actual.Length -gt 0) { return $actual[0] } return $candidate } } return '' } hidden static [Exception] NotFound([string] $path, [bool] $walk) { $suffix = if ($walk) { ' (or any of the parent directories).' } else { '' } $message = "task: No Taskfile found at `"$path`"$suffix Run ``New-Taskfile`` to create a new Taskfile." return [TaskError]::Create($message, [TaskError]::TaskfileNotFound) } # Resolves the entrypoint and the execution directory like `task --taskfile/--dir/--global`. static [object] Find([string] $taskfile, [string] $directory, [bool] $global, [string] $workingDirectory) { $cwd = if ([string]::IsNullOrEmpty($workingDirectory)) { [Environment]::CurrentDirectory } else { $workingDirectory } if ($global) { $userHome = [TaskPath]::Home() $entry = [TaskfileReader]::FindInDirectory($userHome) if ([string]::IsNullOrEmpty($entry)) { throw [TaskfileReader]::NotFound($userHome, $false) } return [pscustomobject]@{ Entrypoint = $entry; Dir = $userHome } } if (-not [string]::IsNullOrEmpty($taskfile)) { [TaskPath]::AssertLocal($taskfile) $path = [TaskPath]::Full($cwd, $taskfile) $entry = '' if ([System.IO.Directory]::Exists($path)) { $entry = [TaskfileReader]::FindInDirectory($path) } elseif ([System.IO.File]::Exists($path)) { $entry = $path } if ([string]::IsNullOrEmpty($entry)) { throw [TaskfileReader]::NotFound($taskfile, $false) } $dir = if ([string]::IsNullOrEmpty($directory)) { [System.IO.Path]::GetDirectoryName($entry) } else { [TaskPath]::Full($cwd, $directory) } return [pscustomobject]@{ Entrypoint = $entry; Dir = $dir } } $start = if ([string]::IsNullOrEmpty($directory)) { $cwd } else { [TaskPath]::Full($cwd, $directory) } $current = $start while (-not [string]::IsNullOrEmpty($current)) { if ([System.IO.Directory]::Exists($current)) { $entry = [TaskfileReader]::FindInDirectory($current) if (-not [string]::IsNullOrEmpty($entry)) { return [pscustomobject]@{ Entrypoint = $entry; Dir = $current } } } $current = [System.IO.Path]::GetDirectoryName($current) } throw [TaskfileReader]::NotFound($start, $true) } # ---- model helpers ---------------------------------------------------------------------- hidden static [Exception] Invalid([string] $path, [object] $node, [string] $message) { $detail = if ($null -ne $node) { "line $([TaskYaml]::Line($node)): $message" } else { $message } return [TaskError]::Create("task: Failed to parse ${path}:`n$detail", [TaskError]::TaskfileDecode) } # Child node of a mapping ($null when missing). $map comes from [TaskYaml]::Map(). hidden static [object] Get([OrderedDictionary] $map, [string] $key) { if ($map.Contains($key)) { return $map[$key] } return $null } static [OrderedDictionary] NewMap() { return [OrderedDictionary]::new() } # NOTE: never assign a value that may be a collection through an `if` expression # (`$x = if (...) { $list }`): PowerShell enumerates the result, so a one-element list # silently becomes its element and an empty list becomes $null. static [object] NewVar([string] $kind, [object] $value) { $var = [pscustomobject]@{ Kind = $kind; Value = $null; Sh = $null; Ref = $null; Dir = '' } if ($kind -eq 'sh') { $var.Sh = [string]$value } elseif ($kind -eq 'ref') { $var.Ref = [string]$value } else { $var.Value = $value } return $var } static [object] CopyVar([object] $var, [string] $dir) { return [pscustomobject]@{ Kind = $var.Kind; Value = $var.Value; Sh = $var.Sh; Ref = $var.Ref; Dir = $dir } } # ---- parsing (everything below works on YAML nodes) ---------------------------------------- hidden static [object] ParseVar([object] $node, [string] $path) { if ([TaskYaml]::IsMap($node)) { $map = [TaskYaml]::Map($node) if ($map.Contains('sh')) { return [TaskfileReader]::NewVar('sh', [TaskYaml]::Str($map['sh'])) } if ($map.Contains('ref')) { return [TaskfileReader]::NewVar('ref', [TaskYaml]::Str($map['ref'])) } if ($map.Contains('map')) { return [TaskfileReader]::NewVar('static', [TaskYaml]::Value($map['map'])) } $first = '<none>' foreach ($key in $map.get_Keys()) { $first = [string]$key; break } throw [TaskfileReader]::Invalid($path, $node, "`"$first`" is not a valid variable type. Try `"sh`", `"ref`", `"map`" or using a scalar value") } return [TaskfileReader]::NewVar('static', [TaskYaml]::Value($node)) } hidden static [OrderedDictionary] ParseVars([object] $node, [string] $path) { $vars = [TaskfileReader]::NewMap() $kind = [TaskYaml]::Kind($node) if ($kind -eq 'null') { return $vars } if ($kind -ne 'map') { throw [TaskfileReader]::Invalid($path, $node, 'vars and env must be maps') } $map = [TaskYaml]::Map($node) foreach ($key in $map.get_Keys()) { $vars[[string]$key] = [TaskfileReader]::ParseVar($map[$key], $path) } return $vars } hidden static [List[object]] ParsePlatforms([object] $node, [string] $path) { $result = [List[object]]::new() foreach ($item in [TaskYaml]::StrList($node)) { $parts = $item.Split('/') $os = '' $arch = '' if ($parts.Count -eq 1) { if ([TaskfileReader]::KnownOS -ccontains $parts[0]) { $os = $parts[0] } elseif ([TaskfileReader]::KnownArch -ccontains $parts[0]) { $arch = $parts[0] } else { throw [TaskfileReader]::Invalid($path, $node, "invalid platform `"$item`"") } } elseif ($parts.Count -eq 2 -and [TaskfileReader]::KnownOS -ccontains $parts[0] -and [TaskfileReader]::KnownArch -ccontains $parts[1]) { $os = $parts[0] $arch = $parts[1] } else { throw [TaskfileReader]::Invalid($path, $node, "invalid platform `"$item`"") } $result.Add([pscustomobject]@{ OS = $os; Arch = $arch }) } return $result } hidden static [List[object]] ParseGlobs([object] $node) { $result = [List[object]]::new() foreach ($item in [TaskYaml]::Seq($node)) { if ([TaskYaml]::IsMap($item)) { $map = [TaskYaml]::Map($item) if ($map.Contains('exclude')) { $result.Add([pscustomobject]@{ Glob = [TaskYaml]::Str($map['exclude']); Negate = $true }) } continue } $result.Add([pscustomobject]@{ Glob = [TaskYaml]::Str($item); Negate = $false }) } return $result } hidden static [object] ParseFor([object] $node, [string] $path) { $for = [pscustomobject]@{ List = $null; Matrix = $null; From = ''; Var = ''; Split = ''; As = '' } $kind = [TaskYaml]::Kind($node) if ($kind -eq 'map') { $map = [TaskYaml]::Map($node) $for.Var = [TaskYaml]::Str([TaskfileReader]::Get($map, 'var')) $for.Split = [TaskYaml]::Str([TaskfileReader]::Get($map, 'split')) $for.As = [TaskYaml]::Str([TaskfileReader]::Get($map, 'as')) $matrix = [TaskYaml]::Map([TaskfileReader]::Get($map, 'matrix')) if ($matrix.get_Count() -gt 0) { if ($for.Var.Length -gt 0) { throw [TaskfileReader]::Invalid($path, $node, 'cannot use both var and matrix in for') } $rows = [TaskfileReader]::NewMap() foreach ($key in $matrix.get_Keys()) { $row = $matrix[$key] if ([TaskYaml]::IsMap($row)) { $rowMap = [TaskYaml]::Map($row) if (-not $rowMap.Contains('ref')) { throw [TaskfileReader]::Invalid($path, $row, 'matrix values must be a list or a ref') } $rows[[string]$key] = [pscustomobject]@{ Value = $null; Ref = [TaskYaml]::Str($rowMap['ref']) } } else { $values = [List[object]]::new() foreach ($item in [TaskYaml]::Seq($row)) { $values.Add([TaskYaml]::Value($item)) } $rows[[string]$key] = [pscustomobject]@{ Value = $values; Ref = '' } } } $for.Matrix = $rows } elseif ($for.Var.Length -eq 0) { throw [TaskfileReader]::Invalid($path, $node, 'invalid keys in for') } return $for } if ($kind -eq 'scalar') { $from = [TaskYaml]::Str($node) if ($from -cne 'sources' -and $from -cne 'generates') { throw [TaskfileReader]::Invalid($path, $node, "invalid for value `"$from`"") } $for.From = $from return $for } $list = [List[object]]::new() foreach ($item in [TaskYaml]::Seq($node)) { $list.Add([TaskYaml]::Value($item)) } $for.List = $list return $for } hidden static [object] NewCmd() { return [pscustomobject]@{ Cmd = ''; Task = ''; Vars = $null; Silent = $false; IgnoreError = $false; Defer = $false Platforms = [List[object]]::new(); Set = [string[]]@(); Shopt = [string[]]@(); For = $null; If = '' } } hidden static [object] ParseCmd([object] $node, [string] $path) { $cmd = [TaskfileReader]::NewCmd() if (-not [TaskYaml]::IsMap($node)) { $cmd.Cmd = [TaskYaml]::Str($node) return $cmd } $map = [TaskYaml]::Map($node) $cmd.Silent = [TaskYaml]::Bool([TaskfileReader]::Get($map, 'silent')) if ($map.Contains('defer')) { $defer = $map['defer'] $cmd.Defer = $true if ([TaskYaml]::IsMap($defer)) { $deferMap = [TaskYaml]::Map($defer) $cmd.Task = [TaskYaml]::Str([TaskfileReader]::Get($deferMap, 'task')) $cmd.Vars = [TaskfileReader]::ParseVars([TaskfileReader]::Get($deferMap, 'vars'), $path) $cmd.Silent = [TaskYaml]::Bool([TaskfileReader]::Get($deferMap, 'silent')) if ($cmd.Task.Length -eq 0) { throw [TaskfileReader]::Invalid($path, $defer, 'invalid keys in defer') } } else { $cmd.Cmd = [TaskYaml]::Str($defer) } return $cmd } if ($map.Contains('for')) { $cmd.For = [TaskfileReader]::ParseFor($map['for'], $path) } $cmd.IgnoreError = [TaskYaml]::Bool([TaskfileReader]::Get($map, 'ignore_error')) $cmd.If = [TaskYaml]::Str([TaskfileReader]::Get($map, 'if')) $task = [TaskYaml]::Str([TaskfileReader]::Get($map, 'task')) if ($task.Length -gt 0) { $cmd.Task = $task $cmd.Vars = [TaskfileReader]::ParseVars([TaskfileReader]::Get($map, 'vars'), $path) return $cmd } $text = [TaskYaml]::Str([TaskfileReader]::Get($map, 'cmd')) if ($text.Length -gt 0) { $cmd.Cmd = $text $cmd.Platforms = [TaskfileReader]::ParsePlatforms([TaskfileReader]::Get($map, 'platforms'), $path) $cmd.Set = [TaskYaml]::StrList([TaskfileReader]::Get($map, 'set')) $cmd.Shopt = [TaskYaml]::StrList([TaskfileReader]::Get($map, 'shopt')) return $cmd } throw [TaskfileReader]::Invalid($path, $node, 'invalid keys in command') } hidden static [object] ParseDep([object] $node, [string] $path) { $dep = [pscustomobject]@{ Task = ''; Vars = $null; Silent = $false; For = $null } if (-not [TaskYaml]::IsMap($node)) { $dep.Task = [TaskYaml]::Str($node) return $dep } $map = [TaskYaml]::Map($node) $dep.Task = [TaskYaml]::Str([TaskfileReader]::Get($map, 'task')) $dep.Vars = [TaskfileReader]::ParseVars([TaskfileReader]::Get($map, 'vars'), $path) $dep.Silent = [TaskYaml]::Bool([TaskfileReader]::Get($map, 'silent')) if ($map.Contains('for')) { $dep.For = [TaskfileReader]::ParseFor($map['for'], $path) } if ($dep.Task.Length -eq 0) { throw [TaskfileReader]::Invalid($path, $node, 'invalid keys in dependency') } return $dep } hidden static [object] NewTask([string] $name, [string] $path) { return [pscustomobject]@{ Task = $name Cmds = [List[object]]::new() Deps = [List[object]]::new() Label = '' Desc = '' Prompt = [string[]]@() Summary = '' Aliases = [string[]]@() Sources = [List[object]]::new() Generates = [List[object]]::new() Status = [string[]]@() Preconditions = [List[object]]::new() Requires = [List[object]]::new() Dir = '' Vars = [TaskfileReader]::NewMap() Env = [TaskfileReader]::NewMap() Dotenv = [string[]]@() Silent = $null Interactive = $false Internal = $false Method = '' Prefix = '' IgnoreError = $false Run = '' Platforms = [List[object]]::new() Set = [string[]]@() Shopt = [string[]]@() Watch = $false If = '' Failfast = $false Namespace = '' IncludeVars = [TaskfileReader]::NewMap() IncludedTaskfileVars = [TaskfileReader]::NewMap() Location = [pscustomobject]@{ Taskfile = $path; Line = 0; Column = 0 } } } hidden static [object] ParseTask([string] $name, [object] $node, [string] $path) { $task = [TaskfileReader]::NewTask($name, $path) $kind = [TaskYaml]::Kind($node) if ($kind -eq 'null') { return $task } if ($kind -ne 'map') { foreach ($item in [TaskYaml]::Seq($node)) { $task.Cmds.Add([TaskfileReader]::ParseCmd($item, $path)) } return $task } $map = [TaskYaml]::Map($node) if ($map.Contains('cmds')) { foreach ($item in [TaskYaml]::Seq($map['cmds'])) { $task.Cmds.Add([TaskfileReader]::ParseCmd($item, $path)) } } elseif ($map.Contains('cmd')) { $task.Cmds.Add([TaskfileReader]::ParseCmd($map['cmd'], $path)) } foreach ($item in [TaskYaml]::Seq([TaskfileReader]::Get($map, 'deps'))) { $task.Deps.Add([TaskfileReader]::ParseDep($item, $path)) } $task.Label = [TaskYaml]::Str([TaskfileReader]::Get($map, 'label')) $task.Desc = [TaskYaml]::Str([TaskfileReader]::Get($map, 'desc')) $task.Prompt = [TaskYaml]::StrList([TaskfileReader]::Get($map, 'prompt')) $task.Summary = [TaskYaml]::Str([TaskfileReader]::Get($map, 'summary')) $task.Aliases = [TaskYaml]::StrList([TaskfileReader]::Get($map, 'aliases')) $task.Sources = [TaskfileReader]::ParseGlobs([TaskfileReader]::Get($map, 'sources')) $task.Generates = [TaskfileReader]::ParseGlobs([TaskfileReader]::Get($map, 'generates')) $task.Status = [TaskYaml]::StrList([TaskfileReader]::Get($map, 'status')) foreach ($item in [TaskYaml]::Seq([TaskfileReader]::Get($map, 'preconditions'))) { $sh = '' $msg = '' if ([TaskYaml]::IsMap($item)) { $precondition = [TaskYaml]::Map($item) $sh = [TaskYaml]::Str([TaskfileReader]::Get($precondition, 'sh')) $msg = [TaskYaml]::Str([TaskfileReader]::Get($precondition, 'msg')) } else { $sh = [TaskYaml]::Str($item) } if ($msg.Length -eq 0) { $msg = "``$sh`` failed" } $task.Preconditions.Add([pscustomobject]@{ Sh = $sh; Msg = $msg }) } $requires = [TaskYaml]::Map([TaskfileReader]::Get($map, 'requires')) foreach ($item in [TaskYaml]::Seq([TaskfileReader]::Get($requires, 'vars'))) { if ([TaskYaml]::IsMap($item)) { $required = [TaskYaml]::Map($item) $entry = [pscustomobject]@{ Name = [TaskYaml]::Str([TaskfileReader]::Get($required, 'name')); Enum = $null } if ($required.Contains('enum')) { $entry.Enum = [TaskYaml]::StrList($required['enum']) } $task.Requires.Add($entry) } else { $task.Requires.Add([pscustomobject]@{ Name = [TaskYaml]::Str($item); Enum = $null }) } } $task.Dir = [TaskYaml]::Str([TaskfileReader]::Get($map, 'dir')) $task.Vars = [TaskfileReader]::ParseVars([TaskfileReader]::Get($map, 'vars'), $path) $task.Env = [TaskfileReader]::ParseVars([TaskfileReader]::Get($map, 'env'), $path) $task.Dotenv = [TaskYaml]::StrList([TaskfileReader]::Get($map, 'dotenv')) if ([TaskYaml]::Kind([TaskfileReader]::Get($map, 'silent')) -ne 'null') { $task.Silent = [TaskYaml]::Bool($map['silent']) } $task.Interactive = [TaskYaml]::Bool([TaskfileReader]::Get($map, 'interactive')) $task.Internal = [TaskYaml]::Bool([TaskfileReader]::Get($map, 'internal')) $task.Method = [TaskYaml]::Str([TaskfileReader]::Get($map, 'method')) $task.Prefix = [TaskYaml]::Str([TaskfileReader]::Get($map, 'prefix')) $task.IgnoreError = [TaskYaml]::Bool([TaskfileReader]::Get($map, 'ignore_error')) $task.Run = [TaskYaml]::Str([TaskfileReader]::Get($map, 'run')) $task.Platforms = [TaskfileReader]::ParsePlatforms([TaskfileReader]::Get($map, 'platforms'), $path) $task.Set = [TaskYaml]::StrList([TaskfileReader]::Get($map, 'set')) $task.Shopt = [TaskYaml]::StrList([TaskfileReader]::Get($map, 'shopt')) $task.Watch = [TaskYaml]::Bool([TaskfileReader]::Get($map, 'watch')) $task.If = [TaskYaml]::Str([TaskfileReader]::Get($map, 'if')) $task.Failfast = [TaskYaml]::Bool([TaskfileReader]::Get($map, 'failfast')) return $task } hidden static [object] ParseOutput([object] $node, [string] $path) { $output = [pscustomobject]@{ Name = '' Group = [pscustomobject]@{ Begin = ''; End = ''; ErrorOnly = $false } Prefixed = [pscustomobject]@{ Color = $false } } $kind = [TaskYaml]::Kind($node) if ($kind -eq 'null') { return $output } if ($kind -eq 'map') { $map = [TaskYaml]::Map($node) if ($map.Contains('group')) { $group = [TaskYaml]::Map($map['group']) $output.Name = 'group' $output.Group.Begin = [TaskYaml]::Str([TaskfileReader]::Get($group, 'begin')) $output.Group.End = [TaskYaml]::Str([TaskfileReader]::Get($group, 'end')) $output.Group.ErrorOnly = [TaskYaml]::Bool([TaskfileReader]::Get($group, 'error_only')) } elseif ($map.Contains('prefixed')) { $output.Name = 'prefixed' $output.Prefixed.Color = [TaskYaml]::Bool([TaskfileReader]::Get([TaskYaml]::Map($map['prefixed']), 'color')) } else { throw [TaskfileReader]::Invalid($path, $node, 'output style must be one of interleaved, group or prefixed') } return $output } $name = [TaskYaml]::Str($node) if ($name -cnotin @('interleaved', 'group', 'prefixed')) { throw [TaskfileReader]::Invalid($path, $node, "output style `"$name`" not recognized") } $output.Name = $name return $output } hidden static [List[object]] ParseIncludes([object] $node, [string] $path) { $result = [List[object]]::new() $kind = [TaskYaml]::Kind($node) if ($kind -eq 'null') { return $result } if ($kind -ne 'map') { throw [TaskfileReader]::Invalid($path, $node, 'includes must be a map') } $map = [TaskYaml]::Map($node) foreach ($key in $map.get_Keys()) { $value = $map[$key] $include = [pscustomobject]@{ Namespace = [string]$key; Taskfile = ''; Dir = ''; Optional = $false; Internal = $false; Flatten = $false Aliases = [string[]]@(); Vars = [TaskfileReader]::NewMap(); Excludes = [string[]]@(); AdvancedImport = $false } if ([TaskYaml]::IsMap($value)) { $options = [TaskYaml]::Map($value) $include.AdvancedImport = $true $include.Taskfile = [TaskYaml]::Str([TaskfileReader]::Get($options, 'taskfile')) $include.Dir = [TaskYaml]::Str([TaskfileReader]::Get($options, 'dir')) $include.Optional = [TaskYaml]::Bool([TaskfileReader]::Get($options, 'optional')) $include.Internal = [TaskYaml]::Bool([TaskfileReader]::Get($options, 'internal')) $include.Flatten = [TaskYaml]::Bool([TaskfileReader]::Get($options, 'flatten')) $include.Aliases = [TaskYaml]::StrList([TaskfileReader]::Get($options, 'aliases')) $include.Vars = [TaskfileReader]::ParseVars([TaskfileReader]::Get($options, 'vars'), $path) $include.Excludes = [TaskYaml]::StrList([TaskfileReader]::Get($options, 'excludes')) } else { $include.Taskfile = [TaskYaml]::Str($value) } if ($include.Taskfile.Length -eq 0) { throw [TaskfileReader]::Invalid($path, $value, "include `"$key`" has no taskfile") } $result.Add($include) } return $result } # Reads one Taskfile (no includes merged yet). static [object] ReadFile([string] $path) { $text = [System.IO.File]::ReadAllText($path) $root = $null try { $root = [TaskYaml]::Load($text) } catch { $cause = [TaskError]::Unwrap($_.Exception) # YamlDotNet throws a bare System.Exception for some malformed flow collections. $detail = if ($cause.GetType() -eq [System.Exception]) { "invalid YAML syntax (values containing '{{ }}' inside [ ] or { } must be quoted)" } else { $cause.Message } throw [TaskError]::Create("task: Failed to parse ${path}:`nyaml: $detail", [TaskError]::TaskfileInvalid) } if ([TaskYaml]::Kind($root) -ne 'map') { throw [TaskfileReader]::Invalid($path, $null, 'the Taskfile must be a YAML map') } $document = [TaskYaml]::Map($root) $version = [TaskYaml]::Str([TaskfileReader]::Get($document, 'version')) if ($version.Length -eq 0) { throw [TaskError]::Create("task: Failed to parse ${path}:`nversion: not specified (use version: '3')", [TaskError]::TaskfileVersionCheck) } $major = 0 if (-not [int]::TryParse(($version -split '\.')[0], [ref] $major)) { throw [TaskError]::Create("task: Failed to parse ${path}:`ninvalid version `"$version`"", [TaskError]::TaskfileVersionCheck) } if ($major -lt 3) { throw [TaskError]::Create('task: Taskfile schemas prior to v3 are no longer supported', [TaskError]::TaskfileVersionCheck) } if ($major -gt 3) { throw [TaskError]::Create("task: Taskfile schema version `"$version`" is not supported (supported: 3)", [TaskError]::TaskfileVersionCheck) } $taskfile = [pscustomobject]@{ Location = $path Dir = [System.IO.Path]::GetDirectoryName($path) Version = $version Output = [TaskfileReader]::ParseOutput([TaskfileReader]::Get($document, 'output'), $path) Method = [TaskYaml]::Str([TaskfileReader]::Get($document, 'method')) Run = [TaskYaml]::Str([TaskfileReader]::Get($document, 'run')) Silent = [TaskYaml]::Bool([TaskfileReader]::Get($document, 'silent')) Interval = [TaskYaml]::Str([TaskfileReader]::Get($document, 'interval')) Set = [TaskYaml]::StrList([TaskfileReader]::Get($document, 'set')) Shopt = [TaskYaml]::StrList([TaskfileReader]::Get($document, 'shopt')) Dotenv = [TaskYaml]::StrList([TaskfileReader]::Get($document, 'dotenv')) Vars = [TaskfileReader]::ParseVars([TaskfileReader]::Get($document, 'vars'), $path) Env = [TaskfileReader]::ParseVars([TaskfileReader]::Get($document, 'env'), $path) Includes = [TaskfileReader]::ParseIncludes([TaskfileReader]::Get($document, 'includes'), $path) Tasks = [TaskfileReader]::NewMap() } $tasksNode = [TaskfileReader]::Get($document, 'tasks') $tasksKind = [TaskYaml]::Kind($tasksNode) if ($tasksKind -ne 'null') { if ($tasksKind -ne 'map') { throw [TaskfileReader]::Invalid($path, $tasksNode, 'tasks must be a map') } $tasks = [TaskYaml]::Map($tasksNode) $keyNodes = [TaskYaml]::KeyNodes($tasksNode) foreach ($key in $tasks.get_Keys()) { $name = [string]$key $task = [TaskfileReader]::ParseTask($name, $tasks[$key], $path) if ($keyNodes.Contains($name)) { $task.Location.Line = [TaskYaml]::Line($keyNodes[$name]) $task.Location.Column = [TaskYaml]::Column($keyNodes[$name]) } $taskfile.Tasks[$name] = $task } } return $taskfile } # ---- includes --------------------------------------------------------------------------------- hidden static [string] WithNamespace([string] $taskName, [string] $namespace) { if ($taskName.StartsWith(':')) { return $taskName.Substring(1) } return "${namespace}:$taskName" } hidden static [void] Merge([object] $target, [object] $source, [object] $include) { foreach ($key in @($source.Vars.get_Keys())) { $var = $source.Vars[$key] if ($include.AdvancedImport) { $var = [TaskfileReader]::CopyVar($var, $include.Dir) } $target.Vars[[string]$key] = $var } foreach ($key in @($source.Env.get_Keys())) { $var = $source.Env[$key] if ($include.AdvancedImport) { $var = [TaskfileReader]::CopyVar($var, $include.Dir) } $target.Env[[string]$key] = $var } if ($source.Output.Name.Length -gt 0) { $target.Output = $source.Output } $namespace = $include.Namespace foreach ($name in @($source.Tasks.get_Keys())) { $task = $source.Tasks[$name] $task.Internal = $task.Internal -or $include.Internal if ($include.Excludes -ccontains $name) { continue } $taskName = [string]$name if (-not $include.Flatten) { foreach ($dep in $task.Deps) { if ($dep.Task.Length -gt 0) { $dep.Task = [TaskfileReader]::WithNamespace($dep.Task, $namespace) } } foreach ($cmd in $task.Cmds) { if ($cmd.Task.Length -gt 0) { $cmd.Task = [TaskfileReader]::WithNamespace($cmd.Task, $namespace) } } $originalAliases = $task.Aliases $aliases = [List[string]]::new() foreach ($alias in $originalAliases) { $aliases.Add([TaskfileReader]::WithNamespace($alias, $namespace)) } foreach ($namespaceAlias in $include.Aliases) { $aliases.Add([TaskfileReader]::WithNamespace($task.Task, $namespaceAlias)) foreach ($alias in $originalAliases) { $aliases.Add([TaskfileReader]::WithNamespace($alias, $namespaceAlias)) } } $task.Aliases = $aliases.ToArray() $taskName = [TaskfileReader]::WithNamespace($taskName, $namespace) $task.Namespace = $namespace $task.Task = $taskName } if ($include.AdvancedImport) { $task.Dir = [TaskPath]::SmartJoin($include.Dir, $task.Dir) foreach ($key in @($include.Vars.get_Keys())) { $task.IncludeVars[[string]$key] = $include.Vars[$key] } $copy = [TaskfileReader]::NewMap() foreach ($key in @($target.Vars.get_Keys())) { $copy[[string]$key] = $target.Vars[$key] } $task.IncludedTaskfileVars = $copy } if ($target.Tasks.Contains($taskName) -and $include.Flatten) { throw [TaskError]::Create("task: Found multiple tasks ($taskName) included by `"$namespace`"", [TaskError]::TaskNameConflict) } $target.Tasks[$taskName] = $task } if ($source.Tasks.Contains('default') -and -not $include.Flatten -and -not $target.Tasks.Contains($namespace)) { $defaultName = "${namespace}:default" if ($target.Tasks.Contains($defaultName)) { $defaultTask = $target.Tasks[$defaultName] $defaultTask.Aliases = [string[]](@($defaultTask.Aliases) + @($namespace) + @($include.Aliases)) } } } hidden static [object] LoadFile([string] $path, [List[string]] $stack) { foreach ($visited in $stack) { if ([string]::Equals($visited, $path, [StringComparison]::OrdinalIgnoreCase)) { $previous = $stack[$stack.Count - 1] throw [TaskError]::Create("task: include cycle detected between $previous <--> $path", [TaskError]::TaskfileCycle) } } $taskfile = [TaskfileReader]::ReadFile($path) if ($taskfile.Includes.Count -eq 0) { return $taskfile } $stack.Add($path) $baseDir = [System.IO.Path]::GetDirectoryName($path) foreach ($include in $taskfile.Includes) { # Include paths may use environment variables and the static vars of this Taskfile. $data = [TaskfileReader]::NewMap() $environment = [Environment]::GetEnvironmentVariables() foreach ($key in $environment.get_Keys()) { $data[[string]$key] = [string]$environment[$key] } foreach ($key in $taskfile.Vars.get_Keys()) { $data[[string]$key] = $taskfile.Vars[$key].Value } $include.Taskfile = [TplEngine]::Expand($include.Taskfile, $data) $include.Dir = [TplEngine]::Expand($include.Dir, $data) [TaskPath]::AssertLocal($include.Taskfile) $target = [TaskPath]::Full($baseDir, $include.Taskfile) $entry = '' if ([System.IO.Directory]::Exists($target)) { $entry = [TaskfileReader]::FindInDirectory($target) } elseif ([System.IO.File]::Exists($target)) { $entry = $target } if ([string]::IsNullOrEmpty($entry)) { if ($include.Optional) { continue } throw [TaskfileReader]::NotFound($target, $false) } $include.Dir = [TaskPath]::Full($baseDir, $include.Dir) $included = [TaskfileReader]::LoadFile($entry, $stack) if ($included.Dotenv.Count -gt 0) { throw [TaskError]::Create("task: Included Taskfiles can't have dotenv declarations. Please, move the dotenv declaration to the main Taskfile", [TaskError]::TaskfileInvalid) } [TaskfileReader]::Merge($taskfile, $included, $include) } $stack.RemoveAt($stack.Count - 1) return $taskfile } # Loads the entrypoint with all includes merged. $dir is the execution directory. static [object] Load([string] $entrypoint, [string] $dir) { $taskfile = [TaskfileReader]::LoadFile([System.IO.Path]::GetFullPath($entrypoint), [List[string]]::new()) $taskfile.Dir = if ([string]::IsNullOrEmpty($dir)) { [System.IO.Path]::GetDirectoryName($taskfile.Location) } else { $dir } return $taskfile } } |