diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-26 09:31:06 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-26 09:31:06 +0200 |
| commit | 839ea8d3a60a2799e3ce327e5db83e7d95a8bb39 (patch) | |
| tree | b4a7c84a8fe8be98c6dd2dbd7aff637b4f9e5fd7 | |
| parent | dcded0f3baf82ef7689abf51c41dd33be12b3910 (diff) | |
feat: Support piping expressions via stdin
| -rw-r--r-- | cmd/gt/cli_test.go | 140 | ||||
| -rw-r--r-- | cmd/gt/main.go | 40 |
2 files changed, 177 insertions, 3 deletions
diff --git a/cmd/gt/cli_test.go b/cmd/gt/cli_test.go index 916655f..e725de9 100644 --- a/cmd/gt/cli_test.go +++ b/cmd/gt/cli_test.go @@ -210,6 +210,146 @@ func TestCLIInvalidRPN(t *testing.T) { } } +// TestCLIStdin tests that stdin input works correctly. +func TestCLIStdin(t *testing.T) { + binaryPath := buildBinary(t) + + tests := []struct { + name string + stdin string + expected string + }{ + { + name: "RPN expression via stdin", + stdin: "3 4 +", + expected: "7", + }, + { + name: "Percentage expression via stdin", + stdin: "20% of 150", + expected: "30", + }, + { + name: "Variable assignment via stdin", + stdin: "x 5 = x x +", + expected: "10", + }, + { + name: "Boolean comparison via stdin", + stdin: "5 3 ==", + expected: "false", + }, + { + name: "Boolean to number coercion via stdin", + stdin: "true 2 *", + expected: "2", + }, + { + name: "Complex expression via stdin", + stdin: "2 3 + 4 *", + expected: "20", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := exec.Command(binaryPath) + cmd.Stdin = strings.NewReader(tt.stdin) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("command failed: %v\nOutput: %s", err, string(output)) + } + + outputStr := strings.TrimSpace(string(output)) + if !strings.Contains(outputStr, tt.expected) { + t.Errorf("output should contain '%s', got: %s", tt.expected, outputStr) + } + }) + } +} + +// TestCLIStdinWithPiping tests stdin via pipe (simulating `echo EXP | gt`). +func TestCLIStdinWithPiping(t *testing.T) { + binaryPath := buildBinary(t) + + tests := []struct { + name string + input string + expected string + }{ + { + name: "echo 3 4 +", + input: "3 4 +", + expected: "7", + }, + { + name: "echo 20%% of 150", + input: "20% of 150", + expected: "30", + }, + { + name: "echo x 5 = x x +", + input: "x 5 = x x +", + expected: "10", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Simulate piping: echo INPUT | gt + cmd := exec.Command(binaryPath) + cmd.Stdin = strings.NewReader(tt.input) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("command failed: %v\nOutput: %s", err, string(output)) + } + + outputStr := strings.TrimSpace(string(output)) + if !strings.Contains(outputStr, tt.expected) { + t.Errorf("output should contain '%s', got: %s", tt.expected, outputStr) + } + }) + } +} + +// TestCLIStdinWithMultipleLines tests stdin with multiple lines (should use first line). +func TestCLIStdinWithMultipleLines(t *testing.T) { + binaryPath := buildBinary(t) + + tests := []struct { + name string + stdin string + expected string + }{ + { + name: "Multiple lines - first line used", + stdin: "3 4 +\n5 6 +", + expected: "7", + }, + { + name: "Empty line then expression", + stdin: "\n3 4 +", + expected: "7", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := exec.Command(binaryPath) + cmd.Stdin = strings.NewReader(tt.stdin) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("command failed: %v\nOutput: %s", err, string(output)) + } + + outputStr := strings.TrimSpace(string(output)) + if !strings.Contains(outputStr, tt.expected) { + t.Errorf("output should contain '%s', got: %s", tt.expected, outputStr) + } + }) + } +} + // TestCLIVariableAssignment tests all variable assignment syntaxes. func TestCLIVariableAssignment(t *testing.T) { binaryPath := buildBinary(t) diff --git a/cmd/gt/main.go b/cmd/gt/main.go index 2e3df47..42ddde9 100644 --- a/cmd/gt/main.go +++ b/cmd/gt/main.go @@ -71,7 +71,7 @@ func main() { // runCommand processes command-line arguments and executes the appropriate action. // // It handles: -// - No arguments: Start REPL mode if stdin is a TTY, otherwise show usage +// - No arguments: Start REPL mode if stdin is a TTY, otherwise read from stdin // - "version" argument: Return the version string // - Other arguments: Try RPN parsing first, then fall back to percentage calculation func runCommand(args []string) (string, error) { @@ -83,8 +83,27 @@ func runCommand(args []string) (string, error) { } return "", nil } - printUsage() - return "", fmt.Errorf("no input provided") + // Read from stdin (pipe or redirect) + input, err := readStdin() + if err != nil { + return "", fmt.Errorf("failed to read stdin: %w", err) + } + input = strings.TrimSpace(input) + if input == "" { + printUsage() + return "", fmt.Errorf("no input provided") + } + // Try RPN parsing first + rpnResult, rpnErr := runRPN(input) + if rpnErr == nil { + return rpnResult, nil + } + // Fall back to percentage calculation + result, err := perc.Parse(input) + if err != nil { + return "", fmt.Errorf("rpn fallback failed for input %q: %w", input, err) + } + return result, nil } if args[1] == "version" { @@ -108,6 +127,21 @@ func runCommand(args []string) (string, error) { return result, nil } +// readStdin reads all input from stdin and returns it as a string. +func readStdin() (string, error) { + data, err := os.ReadFile("/dev/stdin") + if err != nil { + // Fallback if /dev/stdin is not available + buf := make([]byte, 4096) + n, err := os.Stdin.Read(buf) + if n > 0 { + return string(buf[:n]), nil + } + return "", err + } + return string(data), nil +} + // runREPL starts the interactive REPL mode. // // It wraps repl.RunREPL() and returns an error if the REPL fails to start. |
