Files
gf-lt/tools/chain.go
2026-04-12 10:39:03 +03:00

417 lines
9.8 KiB
Go

package tools
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// Operator represents a chain operator between commands.
type Operator int
const (
OpNone Operator = iota
OpAnd // &&
OpOr // ||
OpSeq // ;
OpPipe // |
OpRedirect // >
OpAppend // >>
)
// Segment is a single command in a chain.
type Segment struct {
Raw string
Op Operator // operator AFTER this segment
RedirectTo string // file path for > or >>
IsAppend bool // true for >>, false for >
}
// ParseChain splits a command string into segments by &&, ;, |, >, and >>.
// Respects quoted strings (single and double quotes).
func ParseChain(input string) []Segment {
var segments []Segment
var current strings.Builder
runes := []rune(input)
n := len(runes)
for i := 0; i < n; i++ {
ch := runes[i]
// handle quotes
if ch == '\'' || ch == '"' {
quote := ch
current.WriteRune(ch)
i++
for i < n && runes[i] != quote {
current.WriteRune(runes[i])
i++
}
if i < n {
current.WriteRune(runes[i])
}
continue
}
// >>
if ch == '>' && i+1 < n && runes[i+1] == '>' {
cmd := strings.TrimSpace(current.String())
if cmd != "" {
segments = append(segments, Segment{
Raw: cmd,
Op: OpAppend,
})
}
current.Reset()
i++ // skip second >
continue
}
// >
if ch == '>' {
cmd := strings.TrimSpace(current.String())
if cmd != "" {
segments = append(segments, Segment{
Raw: cmd,
Op: OpRedirect,
})
}
current.Reset()
continue
}
// &&
if ch == '&' && i+1 < n && runes[i+1] == '&' {
segments = append(segments, Segment{
Raw: strings.TrimSpace(current.String()),
Op: OpAnd,
})
current.Reset()
i++ // skip second &
continue
}
// ;
if ch == ';' {
segments = append(segments, Segment{
Raw: strings.TrimSpace(current.String()),
Op: OpSeq,
})
current.Reset()
continue
}
// ||
if ch == '|' && i+1 < n && runes[i+1] == '|' {
segments = append(segments, Segment{
Raw: strings.TrimSpace(current.String()),
Op: OpOr,
})
current.Reset()
i++ // skip second |
continue
}
// | (single pipe)
if ch == '|' {
segments = append(segments, Segment{
Raw: strings.TrimSpace(current.String()),
Op: OpPipe,
})
current.Reset()
continue
}
current.WriteRune(ch)
}
// last segment
last := strings.TrimSpace(current.String())
if last != "" {
segments = append(segments, Segment{Raw: last, Op: OpNone})
}
return segments
}
// ExecChain executes a command string with pipe/chaining support.
// Returns the combined output of all commands.
func ExecChain(command string) string {
segments := ParseChain(command)
if len(segments) == 0 {
return "[error] empty command"
}
// Check if we have a redirect
var redirectTo string
var isAppend bool
redirectIdx := -1
for i, seg := range segments {
if seg.Op == OpRedirect || seg.Op == OpAppend {
redirectIdx = i
isAppend = seg.Op == OpAppend
break
}
}
if redirectIdx >= 0 && redirectIdx+1 < len(segments) {
targetPath, err := resolveRedirectPath(segments[redirectIdx+1].Raw)
if err != nil {
return fmt.Sprintf("[error] redirect: %v", err)
}
redirectTo = targetPath
redirectCmd := segments[redirectIdx].Raw
// Check if there's chaining after redirect by looking at the operator of the segment AFTER the target
// The segment at redirectIdx+1 is the target file, and its Op tells us what operator follows
hasChainingAfterRedirect := false
if redirectIdx+1 < len(segments) && segments[redirectIdx+1].Op != OpNone && segments[redirectIdx+1].Op != OpPipe {
hasChainingAfterRedirect = true
}
// Remove both the redirect segment and its target
segments = append(segments[:redirectIdx], segments[redirectIdx+2:]...)
// Execute the redirect command
var lastOutput string
var lastErr error
lastOutput, lastErr = execSingle(redirectCmd, "")
if lastErr != nil {
return fmt.Sprintf("[error] redirect: %v", lastErr)
}
if err := writeFile(redirectTo, lastOutput, isAppend); err != nil {
return fmt.Sprintf("[error] redirect: %v", err)
}
mode := "Wrote"
if isAppend {
mode = "Appended"
}
size := humanSizeChain(int64(len(lastOutput)))
redirectResult := fmt.Sprintf("%s %s → %s", mode, size, filepath.Base(redirectTo))
// If no remaining segments or no chaining, just return the write confirmation
if len(segments) == 0 || !hasChainingAfterRedirect {
return redirectResult
}
// There are remaining commands after the redirect
collected := []string{redirectResult}
// Execute remaining commands
for _, seg := range segments {
lastOutput, lastErr = execSingle(seg.Raw, "")
if lastOutput != "" {
collected = append(collected, lastOutput)
}
}
return strings.Join(collected, "\n")
} else if redirectIdx >= 0 && redirectIdx+1 >= len(segments) {
return "[error] redirect: target file required"
}
var collected []string
var lastOutput string
var lastErr error
pipeInput := ""
for i, seg := range segments {
if i > 0 {
prevOp := segments[i-1].Op
if prevOp == OpAnd && lastErr != nil {
continue
}
if prevOp == OpOr && lastErr == nil {
continue
}
}
segStdin := ""
if i == 0 {
segStdin = pipeInput
} else if segments[i-1].Op == OpPipe {
segStdin = lastOutput
}
lastOutput, lastErr = execSingle(seg.Raw, segStdin)
if i < len(segments)-1 && seg.Op == OpPipe {
continue
}
if lastOutput != "" {
collected = append(collected, lastOutput)
}
}
// Handle redirect if present
if redirectTo != "" {
output := lastOutput
if err := writeFile(redirectTo, output, isAppend); err != nil {
return fmt.Sprintf("[error] redirect: %v", err)
}
mode := "Wrote"
if isAppend {
mode = "Appended"
}
size := humanSizeChain(int64(len(output)))
return fmt.Sprintf("%s %s → %s", mode, size, filepath.Base(redirectTo))
}
return strings.Join(collected, "\n")
}
// execSingle executes a single command (with arguments) and returns output and error.
func execSingle(command, stdin string) (string, error) {
parts := tokenize(command)
if len(parts) == 0 {
return "", errors.New("empty command")
}
name := parts[0]
args := parts[1:]
// Check if it's a built-in Go command
result, err := execBuiltin(name, args, stdin)
if err == nil {
return result, nil
}
// Check if it's a "not a builtin" error (meaning we should try system command)
if err.Error() == "not a builtin" {
// Execute as system command
cmd := exec.Command(name, args...)
if stdin != "" {
cmd.Stdin = strings.NewReader(stdin)
}
output, err := cmd.CombinedOutput()
if err != nil {
return string(output), err
}
return string(output), nil
}
// It's a builtin that returned an error
return result, err
}
// tokenize splits a command string by whitespace, respecting quotes.
func tokenize(input string) []string {
var tokens []string
var current strings.Builder
inQuote := false
var quoteChar rune
for _, ch := range input {
if inQuote {
if ch == quoteChar {
inQuote = false
} else {
current.WriteRune(ch)
}
continue
}
if ch == '\'' || ch == '"' {
inQuote = true
quoteChar = ch
continue
}
if ch == ' ' || ch == '\t' {
if current.Len() > 0 {
tokens = append(tokens, current.String())
current.Reset()
}
continue
}
current.WriteRune(ch)
}
if current.Len() > 0 {
tokens = append(tokens, current.String())
}
return tokens
}
// execBuiltin executes a built-in command if it exists.
func execBuiltin(name string, args []string, stdin string) (string, error) {
var result string
switch name {
case "echo":
result = FsEcho(args, stdin)
case "time":
result = FsTime(args, stdin)
case "cat":
result = FsCat(args, stdin)
case "pwd":
result = FsPwd(args, stdin)
case "cd":
result = FsCd(args, stdin)
case "mkdir":
result = FsMkdir(args, stdin)
case "ls":
result = FsLs(args, stdin)
case "cp":
result = FsCp(args, stdin)
case "mv":
result = FsMv(args, stdin)
case "rm":
result = FsRm(args, stdin)
case "grep":
result = FsGrep(args, stdin)
case "head":
result = FsHead(args, stdin)
case "tail":
result = FsTail(args, stdin)
case "wc":
result = FsWc(args, stdin)
case "sort":
result = FsSort(args, stdin)
case "uniq":
result = FsUniq(args, stdin)
case "sed":
result = FsSed(args, stdin)
case "stat":
result = FsStat(args, stdin)
case "go":
if len(args) == 0 {
return "[error] usage: go <subcommand> [options]", nil
}
cmd := exec.Command("go", args...)
cmd.Dir = cfg.FilePickerDir
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Sprintf("[error] go %s: %v\n%s", args[0], err, string(output)), nil
}
return string(output), nil
default:
return "", errors.New("not a builtin")
}
if strings.HasPrefix(result, "[error]") {
return result, errors.New(result)
}
return result, nil
}
// resolveRedirectPath resolves the target path for a redirect operator
func resolveRedirectPath(path string) (string, error) {
path = strings.TrimSpace(path)
if path == "" {
return "", errors.New("redirect target required")
}
abs, err := resolvePath(path)
if err != nil {
return "", err
}
return abs, nil
}
// writeFile writes content to a file (truncate or append)
func writeFile(path, content string, append bool) error {
flags := os.O_CREATE | os.O_WRONLY
if append {
flags |= os.O_APPEND
} else {
flags |= os.O_TRUNC
}
f, err := os.OpenFile(path, flags, 0644)
if err != nil {
return err
}
defer f.Close()
_, err = f.WriteString(content)
return err
}
// humanSizeChain returns human-readable file size
func humanSizeChain(n int64) string {
switch {
case n >= 1<<20:
return fmt.Sprintf("%.1fMB", float64(n)/float64(1<<20))
case n >= 1<<10:
return fmt.Sprintf("%.1fKB", float64(n)/float64(1<<10))
default:
return fmt.Sprintf("%dB", n)
}
}