Fix (tools): '>' & '>>'
This commit is contained in:
146
tools/chain.go
146
tools/chain.go
@@ -3,7 +3,9 @@ package tools
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,15 +18,19 @@ const (
|
|||||||
OpOr // ||
|
OpOr // ||
|
||||||
OpSeq // ;
|
OpSeq // ;
|
||||||
OpPipe // |
|
OpPipe // |
|
||||||
|
OpRedirect // >
|
||||||
|
OpAppend // >>
|
||||||
)
|
)
|
||||||
|
|
||||||
// Segment is a single command in a chain.
|
// Segment is a single command in a chain.
|
||||||
type Segment struct {
|
type Segment struct {
|
||||||
Raw string
|
Raw string
|
||||||
Op Operator // operator AFTER this segment
|
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 |.
|
// ParseChain splits a command string into segments by &&, ;, |, >, and >>.
|
||||||
// Respects quoted strings (single and double quotes).
|
// Respects quoted strings (single and double quotes).
|
||||||
func ParseChain(input string) []Segment {
|
func ParseChain(input string) []Segment {
|
||||||
var segments []Segment
|
var segments []Segment
|
||||||
@@ -33,6 +39,7 @@ func ParseChain(input string) []Segment {
|
|||||||
n := len(runes)
|
n := len(runes)
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
ch := runes[i]
|
ch := runes[i]
|
||||||
|
|
||||||
// handle quotes
|
// handle quotes
|
||||||
if ch == '\'' || ch == '"' {
|
if ch == '\'' || ch == '"' {
|
||||||
quote := ch
|
quote := ch
|
||||||
@@ -47,6 +54,31 @@ func ParseChain(input string) []Segment {
|
|||||||
}
|
}
|
||||||
continue
|
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] == '&' {
|
if ch == '&' && i+1 < n && runes[i+1] == '&' {
|
||||||
segments = append(segments, Segment{
|
segments = append(segments, Segment{
|
||||||
@@ -102,6 +134,54 @@ func ExecChain(command string) string {
|
|||||||
if len(segments) == 0 {
|
if len(segments) == 0 {
|
||||||
return "[error] empty command"
|
return "[error] empty command"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle redirects: find the segment with OpRedirect or OpAppend
|
||||||
|
// The NEXT segment (if any) is the target file
|
||||||
|
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) {
|
||||||
|
// The segment after redirect is the target path
|
||||||
|
targetPath, err := resolveRedirectPath(segments[redirectIdx+1].Raw)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("[error] redirect: %v", err)
|
||||||
|
}
|
||||||
|
redirectTo = targetPath
|
||||||
|
// Get the redirect command BEFORE removing segments
|
||||||
|
redirectCmd := segments[redirectIdx].Raw
|
||||||
|
// Remove both the redirect segment and its target
|
||||||
|
segments = append(segments[:redirectIdx], segments[redirectIdx+2:]...)
|
||||||
|
|
||||||
|
// Execute the redirect command explicitly
|
||||||
|
var lastOutput string
|
||||||
|
var lastErr error
|
||||||
|
lastOutput, lastErr = execSingle(redirectCmd, "")
|
||||||
|
if lastErr != nil {
|
||||||
|
return fmt.Sprintf("[error] redirect: %v", lastErr)
|
||||||
|
}
|
||||||
|
// Write output to file
|
||||||
|
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)))
|
||||||
|
return fmt.Sprintf("%s %s → %s", mode, size, filepath.Base(redirectTo))
|
||||||
|
} else if redirectIdx >= 0 && redirectIdx+1 >= len(segments) {
|
||||||
|
// Redirect but no target file
|
||||||
|
return "[error] redirect: target file required"
|
||||||
|
}
|
||||||
|
|
||||||
var collected []string
|
var collected []string
|
||||||
var lastOutput string
|
var lastOutput string
|
||||||
var lastErr error
|
var lastErr error
|
||||||
@@ -109,16 +189,13 @@ func ExecChain(command string) string {
|
|||||||
for i, seg := range segments {
|
for i, seg := range segments {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
prevOp := segments[i-1].Op
|
prevOp := segments[i-1].Op
|
||||||
// && semantics: skip if previous failed
|
|
||||||
if prevOp == OpAnd && lastErr != nil {
|
if prevOp == OpAnd && lastErr != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// || semantics: skip if previous succeeded
|
|
||||||
if prevOp == OpOr && lastErr == nil {
|
if prevOp == OpOr && lastErr == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// determine stdin for this segment
|
|
||||||
segStdin := ""
|
segStdin := ""
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
segStdin = pipeInput
|
segStdin = pipeInput
|
||||||
@@ -126,8 +203,6 @@ func ExecChain(command string) string {
|
|||||||
segStdin = lastOutput
|
segStdin = lastOutput
|
||||||
}
|
}
|
||||||
lastOutput, lastErr = execSingle(seg.Raw, segStdin)
|
lastOutput, lastErr = execSingle(seg.Raw, segStdin)
|
||||||
// pipe: output flows to next command's stdin
|
|
||||||
// && or ;: collect output
|
|
||||||
if i < len(segments)-1 && seg.Op == OpPipe {
|
if i < len(segments)-1 && seg.Op == OpPipe {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -135,6 +210,21 @@ func ExecChain(command string) string {
|
|||||||
collected = append(collected, 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")
|
return strings.Join(collected, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,8 +294,6 @@ func tokenize(input string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// execBuiltin executes a built-in command if it exists.
|
// execBuiltin executes a built-in command if it exists.
|
||||||
// Returns (result, true) if it was a built-in (even if result is empty).
|
|
||||||
// Returns ("", false) if it's not a built-in command.
|
|
||||||
func execBuiltin(name string, args []string, stdin string) (string, error) {
|
func execBuiltin(name string, args []string, stdin string) (string, error) {
|
||||||
var result string
|
var result string
|
||||||
switch name {
|
switch name {
|
||||||
@@ -264,3 +352,45 @@ func execBuiltin(name string, args []string, stdin string) (string, error) {
|
|||||||
}
|
}
|
||||||
return result, nil
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -391,3 +391,33 @@ func TestChaining(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRedirect(t *testing.T) {
|
||||||
|
tmpFile := filepath.Join(cfg.FilePickerDir, "test_redirect.txt")
|
||||||
|
os.Remove(tmpFile)
|
||||||
|
defer os.Remove(tmpFile)
|
||||||
|
|
||||||
|
// Test echo >
|
||||||
|
result1 := ExecChain("echo hello world > " + tmpFile)
|
||||||
|
if !strings.Contains(result1, "Wrote") {
|
||||||
|
t.Errorf("echo > failed: %q", result1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test cat
|
||||||
|
result2 := ExecChain("cat " + tmpFile)
|
||||||
|
if !strings.Contains(result2, "hello") {
|
||||||
|
t.Errorf("cat failed: %q", result2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test echo >>
|
||||||
|
result3 := ExecChain("echo more >> " + tmpFile)
|
||||||
|
if !strings.Contains(result3, "Appended") {
|
||||||
|
t.Errorf("echo >> failed: %q", result3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test cat after append
|
||||||
|
result4 := ExecChain("cat " + tmpFile)
|
||||||
|
if !strings.Contains(result4, "hello") || !strings.Contains(result4, "more") {
|
||||||
|
t.Errorf("cat after append failed: %q", result4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user