summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-24 11:58:45 +0300
committerPaul Buetow <paul@buetow.org>2026-05-24 11:58:45 +0300
commitc705f26cd8f62009cafc1fb064f417926182cccd (patch)
tree244d29e3c828a792cce49dea0dbebd12b59d8977
parent60f2d80da7b197be6f280abf254f946572e060ef (diff)
docs: add variables.md; tests: comprehensive assignment operator tests
- Document all assignment operators (:=, =:, =) with syntax table - Document variable management commands (vars, clear, d) - Document variable lifecycle and practical use cases - Add table-driven tests for := and =: operators - Add tests for = with expression continuation - Add tests for variable reuse in expressions - Add tests for chained assignments - Add tests for vars/clear commands - Add tests for d (delete) operator - Add tests for variable persistence across expressions
-rw-r--r--docs/variables.md176
-rw-r--r--internal/rpn/assignment_test.go248
2 files changed, 424 insertions, 0 deletions
diff --git a/docs/variables.md b/docs/variables.md
new file mode 100644
index 0000000..60474cc
--- /dev/null
+++ b/docs/variables.md
@@ -0,0 +1,176 @@
+# Variables and Assignment Operators
+
+The gt calculator supports named variables that store numeric values. Variables persist across expressions within a single RPN session (REPL or multi-expression input), enabling reusable values and multi-step calculations.
+
+## Assignment Operators
+
+Three assignment operators are available, differing in token order:
+
+| Operator | Syntax | Description | Example |
+|----------|--------|-------------|---------|
+| `:=` | `name value :=` | Right assignment | `x 5 :=` → `x = 5` |
+| `=:` | `value name =:` | Left assignment | `10 y =:` → `y = 10` |
+| `=` | `name value =` or `name = value` | Standard assignment | `x 10 =` or `x = 10` |
+
+### `:=` (Right Assignment)
+
+The value appears on the right (top of stack). This is the most idiomatic RPN form.
+
+```
+x 5 := → x = 5
+price 9.99 := → price = 9.99
+```
+
+### `=:` (Left Assignment)
+
+The value appears on the left (bottom of stack). Useful when you think in "value first" order.
+
+```
+42 answer =: → answer = 42
+3.14159 pi =: → pi = 3.14159
+```
+
+### `=` (Standard Assignment)
+
+Supports both infix-style (`name = value`) and postfix-style (`name value =`) syntax. Also supports expression continuation — you can append an expression after the assignment to evaluate immediately.
+
+```
+x = 5 → x = 5
+x 10 = → x = 10
+x 10 = x 5 + → 15 (assigns x=10, then evaluates x+5)
+```
+
+## Variable Management
+
+### `vars` — List Variables
+
+Shows all defined variables, sorted alphabetically with their values.
+
+```
+vars
+→ No variables defined (when none exist)
+→ a = 10
+ b = 3 (when variables are defined)
+```
+
+### `clear` — Clear All Variables
+
+Removes all defined variables at once.
+
+```
+clear
+→ All variables cleared
+```
+
+### `d` — Delete a Variable
+
+Removes a single variable. The variable name must be pushed as a symbol (using `:` prefix) onto the stack before `d`.
+
+```
+x 5 := → x = 5
+:x d → (deletes x)
+x → :x (x is now undefined, shown as symbol)
+```
+
+Attempting to delete a non-existent variable returns an error:
+
+```
+:nonexistent d → error: variable not found: nonexistent
+```
+
+## Variable Lifecycle
+
+1. **Creation**: A variable is created when a value is assigned using `:=`, `=:`, or `=`.
+2. **Usage**: Reference the variable by name in an expression; its value is pushed onto the stack.
+3. **Reassignment**: Assigning to an existing variable overwrites its previous value.
+4. **Deletion**: Remove with `:name d` or clear all with `clear`.
+5. **Session scope**: Variables persist only within a single RPN session. Each CLI invocation starts fresh.
+
+## Using Variables in Expressions
+
+Once defined, variables are used by name in expressions. The variable's value is pushed onto the stack for computation.
+
+### Single Variable Reuse
+
+```
+x 5 :=
+x x + → 10
+x 2 * → 10
+```
+
+### Multi-Variable Expressions
+
+```
+a 10 :=
+b 3 :=
+a b + → 13
+a b * → 30
+```
+
+### Chained Assignments
+
+Multiple variables can be assigned in a single expression:
+
+```
+a 10 := b 3 := c 2 :=
+a b + c * → 26
+```
+
+### Variable Reassignment
+
+Variables can be reassigned to new values:
+
+```
+x 5 := → x = 5
+x 10 := → x = 10
+x → 10
+```
+
+### Assignment with Expression Continuation
+
+The `=` operator supports evaluating an expression immediately after assignment:
+
+```
+x 10 = x 5 + → 15 (assigns x=10, then computes x+5)
+```
+
+## Practical Use Cases
+
+### Storing Reusable Values
+
+Store constants or frequently-used values to avoid retyping:
+
+```
+pi 3.14159265 :=
+radius 7 :=
+2 pi radius * → 43.9822971 (circumference)
+```
+
+### Multi-Step Calculations
+
+Break complex calculations into named intermediate steps:
+
+```
+base 100 :=
+tax_rate 0.08 :=
+tax base tax_rate * → 8
+total base tax + → 108
+```
+
+### Iterative Refinement
+
+Update a running value across multiple operations:
+
+```
+sum 0 :=
+sum 10 + → 10
+sum 20 + → 30
+sum 5 - → 25
+```
+
+## Reference
+
+- **Implementation**: `internal/rpn/operations_variables.go` (assignment operators, variable CRUD)
+- **Parsing**: `internal/rpn/rpn_parse.go` (`handleAssignmentOp()`, `handleStandardAssign()`)
+- **Registry**: `internal/rpn/operator_registry.go` (operator registration for `:=`, `=:`, `=`, `d`)
+- **Variable store**: `internal/rpn/variables.go` (thread-safe variable storage with save/load)
diff --git a/internal/rpn/assignment_test.go b/internal/rpn/assignment_test.go
index 1b3cfff..8c6a4b2 100644
--- a/internal/rpn/assignment_test.go
+++ b/internal/rpn/assignment_test.go
@@ -4,6 +4,7 @@
package rpn
import (
+ "strings"
"testing"
)
@@ -326,3 +327,250 @@ func TestAssignmentAfterEqualEqual(t *testing.T) {
t.Errorf("x = %v (exists=%v), want 5", val, exists)
}
}
+
+// TestAssignRightOperator tests the := (right assignment) operator via ParseAndEvaluate.
+func TestAssignRightOperator(t *testing.T) {
+ tests := []struct {
+ name string
+ expr string
+ want string
+ }{
+ {"basic right assignment", "x 5 :=", "x = 5"},
+ {"right assignment with decimal", "pi 3.14159 :=", "pi = 3.14159"},
+ {"right assignment with negative value", "neg -10 :=", "neg = -10"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := NewRPN(NewVariables())
+ result, err := r.ParseAndEvaluate(tt.expr)
+ if err != nil {
+ t.Fatalf("ParseAndEvaluate(%q) error = %v", tt.expr, err)
+ }
+ if result != tt.want {
+ t.Errorf("ParseAndEvaluate(%q) = %q, want %q", tt.expr, result, tt.want)
+ }
+ })
+ }
+}
+
+// TestAssignLeftOperator tests the =: (left assignment) operator via ParseAndEvaluate.
+func TestAssignLeftOperator(t *testing.T) {
+ tests := []struct {
+ name string
+ expr string
+ want string
+ }{
+ {"basic left assignment", "10 y =:", "y = 10"},
+ {"left assignment with decimal", "2.71828 e =:", "e = 2.71828"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := NewRPN(NewVariables())
+ result, err := r.ParseAndEvaluate(tt.expr)
+ if err != nil {
+ t.Fatalf("ParseAndEvaluate(%q) error = %v", tt.expr, err)
+ }
+ if result != tt.want {
+ t.Errorf("ParseAndEvaluate(%q) = %q, want %q", tt.expr, result, tt.want)
+ }
+ })
+ }
+}
+
+// TestStandardAssignOperator tests the = (standard assignment) operator via ParseAndEvaluate.
+func TestStandardAssignOperator(t *testing.T) {
+ tests := []struct {
+ name string
+ expr string
+ want string
+ }{
+ {"infix assignment", "x = 5", "x = 5"},
+ {"postfix assignment", "x 10 =", "x = 10"},
+ {"assignment with expression continuation", "x 10 = x 5 +", "15"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := NewRPN(NewVariables())
+ result, err := r.ParseAndEvaluate(tt.expr)
+ if err != nil {
+ t.Fatalf("ParseAndEvaluate(%q) error = %v", tt.expr, err)
+ }
+ if result != tt.want {
+ t.Errorf("ParseAndEvaluate(%q) = %q, want %q", tt.expr, result, tt.want)
+ }
+ })
+ }
+}
+
+// TestVariableInExpression tests using variables in subsequent expressions.
+func TestVariableInExpression(t *testing.T) {
+ tests := []struct {
+ name string
+ setup string
+ expr string
+ want string
+ }{
+ {"single variable reuse", "x 5 :=", "x x +", "10"},
+ {"multi-variable expression", "a 10 := b 3 :=", "a b +", "13"},
+ {"variable in complex expression", "x 5 :=", "x 2 + 3 *", "21"},
+ {"variable reassignment", "x 5 :=", "x 10 := x", "10"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := NewRPN(NewVariables())
+ _, err := r.ParseAndEvaluate(tt.setup)
+ if err != nil {
+ t.Fatalf("Setup failed for %q: %v", tt.setup, err)
+ }
+ result, err := r.ParseAndEvaluate(tt.expr)
+ if err != nil {
+ t.Fatalf("ParseAndEvaluate(%q) error = %v", tt.expr, err)
+ }
+ if result != tt.want {
+ t.Errorf("ParseAndEvaluate(%q) = %q, want %q", tt.expr, result, tt.want)
+ }
+ })
+ }
+}
+
+// TestChainedAssignments tests chaining multiple assignments in one expression.
+func TestChainedAssignments(t *testing.T) {
+ r := NewRPN(NewVariables())
+ result, err := r.ParseAndEvaluate("a 10 := b 3 := c 2 :=")
+ if err != nil {
+ t.Fatalf("Chained assignment failed: %v", err)
+ }
+ // Chained assignments return empty result (side effects)
+ if result != "" {
+ t.Errorf("Chained assignment result = %q, want empty", result)
+ }
+
+ // Verify variables were set
+ if val, exists := r.vars.GetVariable("a"); !exists || val != 10 {
+ t.Errorf("Variable a should be 10, got %v (exists=%v)", val, exists)
+ }
+ if val, exists := r.vars.GetVariable("b"); !exists || val != 3 {
+ t.Errorf("Variable b should be 3, got %v (exists=%v)", val, exists)
+ }
+ if val, exists := r.vars.GetVariable("c"); !exists || val != 2 {
+ t.Errorf("Variable c should be 2, got %v (exists=%v)", val, exists)
+ }
+
+ // Use variables in expression
+ result, err = r.ParseAndEvaluate("a b + c *")
+ if err != nil {
+ t.Fatalf("Expression with variables failed: %v", err)
+ }
+ if result != "26" {
+ t.Errorf("a b + c * = %q, want %q", result, "26")
+ }
+}
+
+// TestVarsCommand tests the vars command.
+func TestVarsCommand(t *testing.T) {
+ r := NewRPN(NewVariables())
+
+ // Empty vars
+ result, err := r.ParseAndEvaluate("vars")
+ if err != nil {
+ t.Fatalf("vars failed: %v", err)
+ }
+ if !strings.Contains(result, "No variables defined") {
+ t.Errorf("Empty vars should say 'No variables defined', got: %q", result)
+ }
+
+ // With variables
+ r.ParseAndEvaluate("z 3 :=")
+ r.ParseAndEvaluate("a 1 :=")
+ r.ParseAndEvaluate("m 2 :=")
+
+ result, err = r.ParseAndEvaluate("vars")
+ if err != nil {
+ t.Fatalf("vars with data failed: %v", err)
+ }
+
+ // Should contain all variable names
+ if !strings.Contains(result, "a") || !strings.Contains(result, "m") || !strings.Contains(result, "z") {
+ t.Errorf("vars should contain a, m, z; got: %q", result)
+ }
+}
+
+// TestClearCommand tests the clear command.
+func TestClearCommand(t *testing.T) {
+ r := NewRPN(NewVariables())
+ r.ParseAndEvaluate("x 5 :=")
+ r.ParseAndEvaluate("y 10 :=")
+
+ result, err := r.ParseAndEvaluate("clear")
+ if err != nil {
+ t.Fatalf("clear failed: %v", err)
+ }
+ if !strings.Contains(result, "All variables cleared") {
+ t.Errorf("clear result = %q, want to contain 'All variables cleared'", result)
+ }
+
+ // Verify variables are gone
+ if r.vars.Count() != 0 {
+ t.Errorf("After clear, count = %d, want 0", r.vars.Count())
+ }
+}
+
+// TestDeleteVariableOperator tests the d (delete) operator.
+func TestDeleteVariableOperator(t *testing.T) {
+ r := NewRPN(NewVariables())
+
+ // Create variable
+ r.ParseAndEvaluate("x 5 :=")
+ _, exists := r.vars.GetVariable("x")
+ if !exists {
+ t.Fatal("Variable x should exist before delete")
+ }
+
+ // Delete with symbol syntax — d is a side-effect operator that leaves
+ // the stack empty, so ParseAndEvaluate returns "empty result". The delete
+ // still succeeds; we verify by checking the variable store directly.
+ _, _ = r.ParseAndEvaluate(":x d")
+
+ // Verify it's gone
+ _, exists = r.vars.GetVariable("x")
+ if exists {
+ t.Error("Variable x should not exist after delete")
+ }
+}
+
+// TestDeleteNonExistentVariableOperator tests deleting a variable that doesn't exist.
+func TestDeleteNonExistentVariableOperator(t *testing.T) {
+ r := NewRPN(NewVariables())
+ _, err := r.ParseAndEvaluate(":nonexistent d")
+ if err == nil {
+ t.Error("Deleting non-existent variable should return error")
+ }
+}
+
+// TestVariablePersistenceAcrossExpressions tests that variables persist across
+// multiple ParseAndEvaluate calls on the same RPN instance.
+func TestVariablePersistenceAcrossExpressions(t *testing.T) {
+ r := NewRPN(NewVariables())
+
+ r.ParseAndEvaluate("a 10 :=")
+ r.ParseAndEvaluate("b 3 :=")
+
+ // Variables should persist
+ result, err := r.ParseAndEvaluate("a b +")
+ if err != nil {
+ t.Fatalf("Expression failed: %v", err)
+ }
+ if result != "13" {
+ t.Errorf("a b + = %q, want %q", result, "13")
+ }
+
+ // Verify vars shows both
+ result, _ = r.ParseAndEvaluate("vars")
+ if !strings.Contains(result, "a") || !strings.Contains(result, "b") {
+ t.Errorf("vars should contain a and b, got: %q", result)
+ }
+}