summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-26 09:31:06 +0200
committerPaul Buetow <paul@buetow.org>2026-03-26 09:31:06 +0200
commit839ea8d3a60a2799e3ce327e5db83e7d95a8bb39 (patch)
treeb4a7c84a8fe8be98c6dd2dbd7aff637b4f9e5fd7
parentdcded0f3baf82ef7689abf51c41dd33be12b3910 (diff)
feat: Support piping expressions via stdin
-rw-r--r--cmd/gt/cli_test.go140
-rw-r--r--cmd/gt/main.go40
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.