Files
vstask/runner/runner_helpers.go

524 lines
12 KiB
Go

package runner
import (
"bufio"
"bytes"
"fmt"
"io"
"maps"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"github.com/chenasraf/vstask/tasks"
"github.com/manifoldco/promptui"
)
func indexByLabel(ts []tasks.Task) map[string]tasks.Task {
m := make(map[string]tasks.Task, len(ts))
for _, t := range ts {
m[t.Label] = t
}
return m
}
func applyPlatformOverrides(t tasks.Task) tasks.Task {
eff := t
switch runtime.GOOS {
case "windows":
if t.Windows != nil {
if t.Windows.Command != "" {
eff.Command = t.Windows.Command
}
if t.Windows.Args != nil {
eff.Args = append([]string(nil), t.Windows.Args...)
}
if t.Windows.Options != nil {
eff.Options = t.Windows.Options
}
if t.Windows.Presentation != nil {
eff.Presentation = t.Windows.Presentation
}
}
case "darwin":
if t.Osx != nil {
if t.Osx.Command != "" {
eff.Command = t.Osx.Command
}
if t.Osx.Args != nil {
eff.Args = append([]string(nil), t.Osx.Args...)
}
if t.Osx.Options != nil {
eff.Options = t.Osx.Options
}
if t.Osx.Presentation != nil {
eff.Presentation = t.Osx.Presentation
}
}
case "linux":
if t.Linux != nil {
if t.Linux.Command != "" {
eff.Command = t.Linux.Command
}
if t.Linux.Args != nil {
eff.Args = append([]string(nil), t.Linux.Args...)
}
if t.Linux.Options != nil {
eff.Options = t.Linux.Options
}
if t.Linux.Presentation != nil {
eff.Presentation = t.Linux.Presentation
}
}
}
return eff
}
// ----------------- Input resolution -----------------
// Expectation for tasks.Input:
// type Input struct {
// ID string `json:"id"`
// Type string `json:"type"` // "promptString" | "pickString" | "command"
// Description string `json:"description"`
// Default string `json:"default"`
// Password bool `json:"password"` // promptString only
// Options []string `json:"options"` // pickString only
// Command string `json:"command"` // command only
// }
type InputResolver struct {
byID map[string]tasks.Input
cache map[string]string
}
func NewInputResolver(inputs []tasks.Input) *InputResolver {
m := make(map[string]tasks.Input, len(inputs))
for _, in := range inputs {
m[in.ID] = in
}
return &InputResolver{
byID: m,
cache: map[string]string{},
}
}
var reInput = regexp.MustCompile(`\$\{input:([^}]+)\}`)
// promptInputsForTask scans the effective task for ${input:*} and resolves all before running.
func promptInputsForTask(t tasks.Task, r *InputResolver) {
ids := collectInputRefsFromTask(t)
for _, id := range ids {
_, _ = r.Resolve(id) // cache it
}
}
func collectInputRefsFromTask(t tasks.Task) []string {
seen := make(map[string]struct{})
grab := func(s string) {
for _, m := range reInput.FindAllStringSubmatch(s, -1) {
if len(m) == 2 {
seen[m[1]] = struct{}{}
}
}
}
grab(t.Command)
for _, a := range t.Args {
grab(a)
}
if t.Options != nil {
grab(t.Options.Cwd)
for _, v := range t.Options.Env {
grab(v)
}
}
out := make([]string, 0, len(seen))
for id := range seen {
out = append(out, id)
}
return out
}
func replaceInputs(s string, r *InputResolver) string {
if s == "" || r == nil {
return s
}
return reInput.ReplaceAllStringFunc(s, func(m string) string {
sub := reInput.FindStringSubmatch(m)
if len(sub) == 2 {
val, _ := r.Resolve(sub[1])
return val
}
return m
})
}
// Resolve returns a value for an input id, prompting if necessary.
// Caches values so the same id is only prompted once.
func (r *InputResolver) Resolve(id string) (string, error) {
if v, ok := r.cache[id]; ok {
return v, nil
}
// Env override (handy for CI): VSTASK_INPUT_<UPPER_ID>
if env := os.Getenv("VSTASK_INPUT_" + strings.ToUpper(id)); env != "" {
r.cache[id] = env
return env, nil
}
in, ok := r.byID[id]
if !ok {
// Unknown input: fallback to simple line prompt.
val, err := simpleLinePrompt(fmt.Sprintf("Enter value for %s", id), "")
if err != nil {
return "", err
}
r.cache[id] = val
return val, nil
}
switch strings.ToLower(in.Type) {
case "promptstring":
lbl := in.Description
if strings.TrimSpace(lbl) == "" {
lbl = fmt.Sprintf("Enter %s", in.ID)
}
val, err := promptString(lbl, in.Default, in.Password)
if err != nil {
return "", err
}
r.cache[id] = val
return val, nil
case "pickstring":
if len(in.Options) == 0 {
// Degenerate case: no options → line prompt with default
lbl := in.Description
if strings.TrimSpace(lbl) == "" {
lbl = fmt.Sprintf("Enter %s", in.ID)
}
val, err := promptString(lbl, in.Default, false)
if err != nil {
return "", err
}
r.cache[id] = val
return val, nil
}
val, err := promptSelect(in.DescriptionOrFallback(), in.Options, in.Default)
if err != nil {
return "", err
}
r.cache[id] = val
return val, nil
case "command":
out := strings.TrimSpace(runInputShell(in.Command))
if out == "" {
// Fallback to default or prompt
if in.Default != "" {
r.cache[id] = in.Default
return in.Default, nil
}
lbl := in.Description
if strings.TrimSpace(lbl) == "" {
lbl = fmt.Sprintf("Enter %s", in.ID)
}
val, err := promptString(lbl, "", false)
if err != nil {
return "", err
}
r.cache[id] = val
return val, nil
}
r.cache[id] = out
return out, nil
default:
// Unknown type → prompt
val, err := promptString(fmt.Sprintf("Enter %s", in.ID), in.Default, false)
if err != nil {
return "", err
}
r.cache[id] = val
return val, nil
}
}
// --- tiny prompt helpers (not fullscreen) ---
// bellFilter strips ASCII BEL (\a) and implements io.WriteCloser.
// Close is a no-op so we never close stdout/stderr.
type bellFilter struct{ w io.Writer }
func (b bellFilter) Write(p []byte) (int, error) {
p = bytes.ReplaceAll(p, []byte{'\a'}, nil)
return b.w.Write(p)
}
func (b bellFilter) Close() error { return nil }
func promptString(label, def string, password bool) (string, error) {
p := promptui.Prompt{
Label: label,
Default: def,
Stdout: bellFilter{os.Stdout},
}
if password {
p.Mask = '*'
}
return p.Run()
}
func promptSelect(label string, options []string, def string) (string, error) {
idx := 0
if def != "" {
for i, o := range options {
if o == def {
idx = i
break
}
}
}
s := promptui.Select{
Label: label,
Items: options,
CursorPos: idx,
Size: minInt(8, maxInt(3, len(options))), // small window; never fullscreen
Stdout: bellFilter{os.Stdout},
}
_, val, err := s.Run()
return val, err
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func simpleLinePrompt(label, def string) (string, error) {
if def != "" {
fmt.Printf("%s [%s]: ", label, def)
} else {
fmt.Printf("%s: ", label)
}
br := bufio.NewReader(os.Stdin)
s, err := br.ReadString('\n')
if err != nil {
return "", err
}
s = strings.TrimRight(s, "\r\n")
if s == "" && def != "" {
return def, nil
}
return s, nil
}
func runInputShell(script string) string {
if strings.TrimSpace(script) == "" {
return ""
}
exe, args := defaultShell()
cmd := exec.Command(exe, append(args, script)...)
// Inherit env and CWD; capture stdout
out, err := cmd.Output()
if err != nil {
return ""
}
return string(out)
}
// ----------------- existing helpers -----------------
func substituteVars(s string, vars map[string]string) string {
if s == "" {
return s
}
out := s
for k, v := range vars {
out = strings.ReplaceAll(out, "${"+k+"}", v)
}
return out
}
func mergeEnv(base []string, extra map[string]string) []string {
// Convert base to map
m := map[string]string{}
for _, kv := range base {
if i := strings.IndexByte(kv, '='); i >= 0 {
m[kv[:i]] = kv[i+1:]
}
}
maps.Copy(m, extra)
// Back to slice
out := make([]string, 0, len(m))
for k, v := range m {
out = append(out, k+"="+v)
}
return out
}
// appendEnvIfMissing adds key=value to env only if key is not already set.
func appendEnvIfMissing(env []string, key, value string) []string {
prefix := key + "="
for _, kv := range env {
if strings.HasPrefix(kv, prefix) {
return env
}
}
return append(env, prefix+value)
}
func defaultShell() (exe string, args []string) {
if runtime.GOOS == "windows" {
return "cmd.exe", []string{"/C"}
}
// Prefer bash if present? Keeping /bin/sh for portability.
return "/bin/sh", []string{"-c"}
}
func buildCommandLine(cmd string, args []string) string {
if runtime.GOOS == "windows" {
parts := make([]string, 0, 1+len(args))
if cmd != "" {
parts = append(parts, winQuote(cmd))
}
for _, a := range args {
parts = append(parts, winQuote(a))
}
return strings.Join(parts, " ")
}
// POSIX: prefer double-quoting so $(...) and $VAR still expand.
if len(args) == 0 {
// Let shell parse/expand everything in command (e.g., $(...), pipes, etc.)
return cmd
}
var b strings.Builder
if cmd != "" {
b.WriteString(cmd) // verbatim, preserves expansions in command
}
for _, a := range args {
if b.Len() > 0 {
b.WriteByte(' ')
}
b.WriteString(posixQuoteForShell(a)) // quote only args
}
return b.String()
}
func posixQuoteForShell(s string) string {
if s == "" {
return `""`
}
// Quote if it has whitespace or shell metachars (including quotes).
if containsAnyRunes(s, " \t\n\r;&|()<>[]{}*?!~`$\\\"'") {
// Escape backslashes and double quotes inside double quotes.
esc := strings.ReplaceAll(s, `\`, `\\`)
esc = strings.ReplaceAll(esc, `"`, `\"`)
return `"` + esc + `"`
}
return s
}
func containsAnyRunes(s, set string) bool {
for _, r := range s {
if strings.ContainsRune(set, r) {
return true
}
}
return false
}
func winQuote(s string) string {
// Very light quoting good enough for cmd.exe /C
if s == "" {
return `""`
}
if strings.IndexFunc(s, func(r rune) bool {
return r <= ' ' || strings.ContainsRune(`"^&|<>()%!`, r)
}) >= 0 {
// escape " by doubling
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
}
return s
}
func mustGetwd() string {
if wd, err := os.Getwd(); err == nil {
return wd
}
// Fallback to HOME if Getwd fails
if home, err := os.UserHomeDir(); err == nil {
return home
}
return ""
}
// Same as buildVSCodeVarMap, but allows overriding ${cwd} with the task's effective cwd.
func buildVSCodeVarMapWithCWD(workspace, cwd string) map[string]string {
vars := buildVSCodeVarMap(workspace)
if cwd != "" {
vars["cwd"] = cwd
}
return vars
}
// buildVSCodeVarMap constructs all built-in VS Code substitutions.
// Many editor-specific values are best-effort via env fallbacks.
func buildVSCodeVarMap(workspace string) map[string]string {
vars := map[string]string{}
// ${userHome}
if home, err := os.UserHomeDir(); err == nil {
vars["userHome"] = home
}
// ${workspaceFolder}, ${workspaceFolderBasename}
if workspace != "" {
vars["workspaceFolder"] = workspace
vars["workspaceFolderBasename"] = filepath.Base(workspace)
}
// ${cwd} (best effort: current process dir)
if wd, err := os.Getwd(); err == nil {
vars["cwd"] = wd
}
// ${execPath} (best effort: env or 'code' on PATH)
if v := os.Getenv("VSCODE_EXEC_PATH"); v != "" {
vars["execPath"] = v
} else if p, _ := exec.LookPath("code"); p != "" {
vars["execPath"] = p
}
// ${defaultBuildTask} (scan tasks)
if all, err := tasks.GetTasks(); err == nil {
for _, t := range all {
if t.Group != nil && strings.EqualFold(t.Group.Kind, "build") && t.Group.IsDefault {
vars["defaultBuildTask"] = t.Label
break
}
}
}
// ${pathSeparator} and ${/}
sep := string(os.PathSeparator)
vars["pathSeparator"] = sep
vars["/"] = sep
return vars
}