diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-22 12:12:55 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-22 12:12:55 +0300 |
| commit | d34ecb37e49a0203a6c4db1fe9ab76e305ae8a66 (patch) | |
| tree | e84f635a4f7c06a6dc531d1e5559a6cdf02d7d5d | |
| parent | cfd98d5bbf08680266e965c15e938aea87c4b832 (diff) | |
feat(rpn): implement metric subcommands
Add multi-word 'metric' command with subcommands for inspecting and
configuring metric units:
- metric show: show metric info (name, category, base unit, factor) for
top of stack
- metric list: list all registered metric categories
- metric <Category>: list all metrics in a specific category (e.g., DataRate)
- metric binary set: set prefix mode to IEC (1024-based)
- metric decimal set: set prefix mode to SI (1000-based)
- metric compatible: check if top two stack values have compatible metrics
Adds prefixMode field to RPN struct with thread-safe getter/setter.
Handles the 'metric' token in evaluate() before falling through to
operators, consuming subsequent tokens as subcommands.
| -rw-r--r-- | internal/rpn/operations.go | 5 | ||||
| -rw-r--r-- | internal/rpn/operations_metric_cmd.go | 99 | ||||
| -rw-r--r-- | internal/rpn/operations_metric_cmd_test.go | 238 | ||||
| -rw-r--r-- | internal/rpn/rpn_parse.go | 46 | ||||
| -rw-r--r-- | internal/rpn/rpn_state.go | 18 |
5 files changed, 406 insertions, 0 deletions
diff --git a/internal/rpn/operations.go b/internal/rpn/operations.go index af30477..d483cc8 100644 --- a/internal/rpn/operations.go +++ b/internal/rpn/operations.go @@ -156,6 +156,11 @@ type Operator interface { PowerIntOperator // SetMode sets the calculation mode for number formatting SetMode(CalculationMode) + // Metric command handlers + MetricShow(stack *Stack) (string, error) + MetricList(stack *Stack) (string, error) + MetricCategory(stack *Stack, categoryName string) (string, error) + MetricCompatible(stack *Stack) (string, error) } // Operations provides operator implementations and stack manipulation. diff --git a/internal/rpn/operations_metric_cmd.go b/internal/rpn/operations_metric_cmd.go new file mode 100644 index 0000000..9a09a67 --- /dev/null +++ b/internal/rpn/operations_metric_cmd.go @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Paul Buetow + +package rpn + +import ( + "fmt" + "sort" + "strings" +) + +// MetricShow shows metric info for top of stack. +func (o *Operations) MetricShow(stack *Stack) (string, error) { + if stack.Len() < 1 { + return "", fmt.Errorf("metric show: stack is empty") + } + val, err := stack.Peek() + if err != nil { + return "", buildError("metric show", err) + } + m := val.Metric() + if m == nil || m.Category == Universal { + return "Cool (Universal)", nil + } + factor := m.Factor(SI) // use SI for display + return fmt.Sprintf("%s, %s, base: %s, factor: %.0g", m.Name, m.Category, m.BaseUnit, factor), nil +} + +// MetricList returns all category names. +func (o *Operations) MetricList(stack *Stack) (string, error) { + reg := o.metricRegistry + all := reg.List() + seen := make(map[Category]bool) + var cats []string + for _, m := range all { + if !seen[m.Category] { + seen[m.Category] = true + cats = append(cats, m.Category.String()) + } + } + sort.Strings(cats) + return strings.Join(cats, ", "), nil +} + +// MetricCategory returns all metric names in the given category. +func (o *Operations) MetricCategory(stack *Stack, categoryName string) (string, error) { + reg := o.metricRegistry + cat, ok := parseCategory(categoryName) + if !ok { + return "", fmt.Errorf("metric: unknown category %q", categoryName) + } + metrics := reg.ListByCategory(cat) + var names []string + for _, m := range metrics { + names = append(names, m.Name) + } + sort.Strings(names) + return strings.Join(names, ", "), nil +} + +// MetricCompatible checks if top two stack metrics are compatible. +func (o *Operations) MetricCompatible(stack *Stack) (string, error) { + if stack.Len() < 2 { + return "", fmt.Errorf("metric compatible: need at least 2 values on stack") + } + vals := stack.Values() + top := vals[len(vals)-1] + second := vals[len(vals)-2] + mA := resolveMetric(o.metricRegistry, second) + mB := resolveMetric(o.metricRegistry, top) + compatible := categoriesCompatible(mA, mB) + result := fmt.Sprintf("%s (%s) and %s (%s): %v", + mA.Name, mA.Category, mB.Name, mB.Category, compatible) + return result, nil +} + +// parseCategory converts a category name string to a Category constant. +func parseCategory(name string) (Category, bool) { + switch name { + case "Universal": + return Universal, true + case "DataRate": + return DataRate, true + case "DataSize": + return DataSize, true + case "Time": + return Time, true + case "Weight": + return Weight, true + case "Speed": + return Speed, true + case "Distance": + return Distance, true + case "Custom": + return Custom, true + default: + return 0, false + } +} diff --git a/internal/rpn/operations_metric_cmd_test.go b/internal/rpn/operations_metric_cmd_test.go new file mode 100644 index 0000000..9b77439 --- /dev/null +++ b/internal/rpn/operations_metric_cmd_test.go @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Paul Buetow + +package rpn + +import ( + "strings" + "testing" +) + +func TestMetricShow(t *testing.T) { + vars := NewVariables() + rpn := NewRPN(vars) + + result, err := rpn.ParseAndEvaluate("100Mbps metric show") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result, "Mbps") { + t.Errorf("expected result to contain 'Mbps', got: %s", result) + } + if !strings.Contains(result, "DataRate") { + t.Errorf("expected result to contain 'DataRate', got: %s", result) + } +} + +func TestMetricShowUniversal(t *testing.T) { + vars := NewVariables() + rpn := NewRPN(vars) + + result, err := rpn.ParseAndEvaluate("42 metric show") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result, "Cool") { + t.Errorf("expected result to contain 'Cool', got: %s", result) + } + if !strings.Contains(result, "Universal") { + t.Errorf("expected result to contain 'Universal', got: %s", result) + } +} + +func TestMetricShowEmptyStack(t *testing.T) { + vars := NewVariables() + rpn := NewRPN(vars) + _, err := rpn.ParseAndEvaluate("metric show") + if err == nil { + t.Error("expected error for empty stack") + } +} + +func TestMetricList(t *testing.T) { + vars := NewVariables() + rpn := NewRPN(vars) + result, err := rpn.ParseAndEvaluate("metric list") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := []string{"DataRate", "DataSize", "Distance", "Speed", "Time", "Universal", "Weight"} + for _, cat := range expected { + if !strings.Contains(result, cat) { + t.Errorf("expected result to contain %q, got: %s", cat, result) + } + } +} + +func TestMetricCategory(t *testing.T) { + vars := NewVariables() + rpn := NewRPN(vars) + result, err := rpn.ParseAndEvaluate("metric DataRate") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := []string{"bps", "Kbps", "Mbps", "Gbps", "Tbps"} + for _, name := range expected { + if !strings.Contains(result, name) { + t.Errorf("expected result to contain %q, got: %s", name, result) + } + } +} + +func TestMetricCategoryTime(t *testing.T) { + vars := NewVariables() + rpn := NewRPN(vars) + result, err := rpn.ParseAndEvaluate("metric Time") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := []string{"ms", "s", "min", "hr", "day"} + for _, name := range expected { + if !strings.Contains(result, name) { + t.Errorf("expected result to contain %q, got: %s", name, result) + } + } +} + +func TestMetricCategoryUnknown(t *testing.T) { + vars := NewVariables() + rpn := NewRPN(vars) + _, err := rpn.ParseAndEvaluate("metric Nope") + if err == nil { + t.Error("expected error for unknown category") + } +} + +func TestMetricSetModeDecimal(t *testing.T) { + vars := NewVariables() + rpn := NewRPN(vars) + + result, err := rpn.ParseAndEvaluate("metric decimal set") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result, "SI") { + t.Errorf("expected SI, got: %s", result) + } + if rpn.GetPrefixMode() != SI { + t.Errorf("prefix mode = %v, want SI", rpn.GetPrefixMode()) + } +} + +func TestMetricSetModeBinary(t *testing.T) { + vars := NewVariables() + rpn := NewRPN(vars) + + result, err := rpn.ParseAndEvaluate("metric binary set") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result, "IEC") { + t.Errorf("expected IEC, got: %s", result) + } + if rpn.GetPrefixMode() != IEC { + t.Errorf("prefix mode = %v, want IEC", rpn.GetPrefixMode()) + } +} + +func TestMetricSetModeToggle(t *testing.T) { + vars := NewVariables() + rpn := NewRPN(vars) + + // Default is SI + if rpn.GetPrefixMode() != SI { + t.Errorf("default prefix mode = %v, want SI", rpn.GetPrefixMode()) + } + + // Switch to IEC + rpn.ParseAndEvaluate("metric binary set") + if rpn.GetPrefixMode() != IEC { + t.Errorf("after binary set, prefix mode = %v, want IEC", rpn.GetPrefixMode()) + } + + // Switch back to SI + rpn.ParseAndEvaluate("metric decimal set") + if rpn.GetPrefixMode() != SI { + t.Errorf("after decimal set, prefix mode = %v, want SI", rpn.GetPrefixMode()) + } +} + +func TestMetricSetModeBinaryIncomplete(t *testing.T) { + vars := NewVariables() + rpn := NewRPN(vars) + _, err := rpn.ParseAndEvaluate("metric binary") + if err == nil { + t.Error("expected error for incomplete 'metric binary'") + } +} + +func TestMetricSetModeDecimalIncomplete(t *testing.T) { + vars := NewVariables() + rpn := NewRPN(vars) + _, err := rpn.ParseAndEvaluate("metric decimal") + if err == nil { + t.Error("expected error for incomplete 'metric decimal'") + } +} + +func TestMetricCompatibleSameCategory(t *testing.T) { + vars := NewVariables() + rpn := NewRPN(vars) + result, err := rpn.ParseAndEvaluate("100Mbps 1Gbps metric compatible") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result, "true") { + t.Errorf("expected Mbps and Gbps to be compatible, got: %s", result) + } +} + +func TestMetricCompatibleDifferentCategory(t *testing.T) { + vars := NewVariables() + rpn := NewRPN(vars) + result, err := rpn.ParseAndEvaluate("100Mbps 2hr metric compatible") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result, "Mbps") { + t.Errorf("expected result to mention Mbps, got: %s", result) + } + if !strings.Contains(result, "hr") { + t.Errorf("expected result to mention hr, got: %s", result) + } + // DataRate and Time are different categories, neither is Universal -> false + if !strings.Contains(result, "false") { + t.Errorf("expected DataRate and Time to be incompatible for +/-, got: %s", result) + } +} + +func TestMetricCompatibleWithUniversal(t *testing.T) { + vars := NewVariables() + rpn := NewRPN(vars) + result, err := rpn.ParseAndEvaluate("100Mbps 42 metric compatible") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Universal (Cool) is compatible with anything + if !strings.Contains(result, "true") { + t.Errorf("expected Cool and Mbps to be compatible, got: %s", result) + } +} + +func TestMetricCompatibleNotEnoughValues(t *testing.T) { + vars := NewVariables() + rpn := NewRPN(vars) + _, err := rpn.ParseAndEvaluate("100 metric compatible") + if err == nil { + t.Error("expected error for insufficient stack values") + } +} + +func TestMetricCompatibleEmptyStack(t *testing.T) { + vars := NewVariables() + rpn := NewRPN(vars) + _, err := rpn.ParseAndEvaluate("metric compatible") + if err == nil { + t.Error("expected error for empty stack") + } +} diff --git a/internal/rpn/rpn_parse.go b/internal/rpn/rpn_parse.go index f115090..5d5ef7d 100644 --- a/internal/rpn/rpn_parse.go +++ b/internal/rpn/rpn_parse.go @@ -349,6 +349,52 @@ func (r *RPN) evaluate(input string, tokens []string) (string, error) { return "", fmt.Errorf("unknown metric %q in %q", metricName, token) } + // Handle multi-word metric command: metric <subcommand> + if token == "metric" && i+1 < len(tokens) { + subCmd := tokens[i+1] + switch subCmd { + case "show": + result, err := r.ops.MetricShow(stack) + if err != nil { + return "", fmt.Errorf("rpn: metric show: %w", err) + } + return result, nil + case "list": + result, err := r.ops.MetricList(stack) + if err != nil { + return "", fmt.Errorf("rpn: metric list: %w", err) + } + return result, nil + case "binary": + if i+2 < len(tokens) && tokens[i+2] == "set" { + r.prefixMode = IEC + i += 2 // skip "binary" and "set" + return "prefix mode: IEC", nil + } + return "", fmt.Errorf("rpn: metric binary: use 'metric binary set'") + case "decimal": + if i+2 < len(tokens) && tokens[i+2] == "set" { + r.prefixMode = SI + i += 2 // skip "decimal" and "set" + return "prefix mode: SI", nil + } + return "", fmt.Errorf("rpn: metric decimal: use 'metric decimal set'") + case "compatible": + result, err := r.ops.MetricCompatible(stack) + if err != nil { + return "", fmt.Errorf("rpn: metric compatible: %w", err) + } + return result, nil + default: + // Try as a category name + result, err := r.ops.MetricCategory(stack, subCmd) + if err != nil { + return "", fmt.Errorf("rpn: metric %s: %w", subCmd, err) + } + return result, nil + } + } + // Check if this is a variable name for assignment (:= or =:) // For := (right assignment): name value := - first token is always a variable name // For =: (left assignment): value name =: - token before =: is a variable name diff --git a/internal/rpn/rpn_state.go b/internal/rpn/rpn_state.go index bf82abc..1d74e58 100644 --- a/internal/rpn/rpn_state.go +++ b/internal/rpn/rpn_state.go @@ -20,6 +20,7 @@ type RPN struct { maxStack int currentStack *Stack mode CalculationMode + prefixMode PrefixMode } // NewRPN creates a new RPN parser and evaluator with the given variable store. @@ -36,6 +37,7 @@ func NewRPN(vars VariableStore) *RPN { maxStack: 1000, // Reasonable limit for RPN expressions currentStack: NewStack(), mode: FloatMode, // Default mode + prefixMode: SI, // Default prefix mode } } @@ -92,3 +94,19 @@ func (r *RPN) SetCurrentStack(values []Number) { func (r *RPN) Stack() []Number { return r.GetCurrentStack() } + +// SetPrefixMode sets the prefix mode (SI or IEC). +// This method is thread-safe for writes. +func (r *RPN) SetPrefixMode(mode PrefixMode) { + r.mu.Lock() + defer r.mu.Unlock() + r.prefixMode = mode +} + +// GetPrefixMode returns the current prefix mode. +// This method is thread-safe for concurrent reads. +func (r *RPN) GetPrefixMode() PrefixMode { + r.mu.RLock() + defer r.mu.RUnlock() + return r.prefixMode +} |
