From 917a3d41ac6c062906362384a1fb691c1083d920 Mon Sep 17 00:00:00 2001 From: Michael Wesemann Date: Mon, 27 Jul 2026 16:14:41 +0200 Subject: [PATCH] initial commit [141.14.140.180,mike] --- .gitignore | 14 + README.md | 197 +++++++++++ ast.go | 63 ++++ build.sh | 40 +++ build_releases.sh | 30 ++ currency.go | 95 +++++ evaluator.go | 568 ++++++++++++++++++++++++++++++ go.mod | 21 ++ go.sum | 78 +++++ goca.go | 642 ++++++++++++++++++++++++++++++++++ main_test.go | 295 ++++++++++++++++ notarize.sh | 53 +++ parser.go | 341 ++++++++++++++++++ types.go | 40 +++ units.go | 108 ++++++ update.go | 127 +++++++ version.go | 6 + web.go | 869 ++++++++++++++++++++++++++++++++++++++++++++++ 18 files changed, 3587 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 ast.go create mode 100755 build.sh create mode 100755 build_releases.sh create mode 100644 currency.go create mode 100644 evaluator.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 goca.go create mode 100644 main_test.go create mode 100755 notarize.sh create mode 100644 parser.go create mode 100644 types.go create mode 100644 units.go create mode 100644 update.go create mode 100644 version.go create mode 100644 web.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d25e86 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.DS_Store +.AppleDouble +.LSOverride +._* +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes + +# Binaries +goca +dist/ +bin/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..bf458a1 --- /dev/null +++ b/README.md @@ -0,0 +1,197 @@ +# 🧮 goca + +> A feature-rich, high-precision, scientific command-line calculator written in Go, powered by an Abstract Syntax Tree (AST) evaluator and arbitrary-precision decimal arithmetic. + +[![Go Version](https://img.shields.io/badge/go-1.18%2B-blue.svg)](https://go.dev/) +[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) + +--- + +## ✨ Features + +* **High-Precision Arithmetic**: Uses arbitrary-precision decimal math (no floating-point rounding errors). +* **Unit Conversions**: Supports Length, Mass, Time, Area, Temperature, Digital Storage, and Angle conversions natively. +* **Live Currency Exchange**: Real-time currency conversion rates fetched automatically and cached. +* **IP & CIDR Utilities**: Built-in network functions to parse IPs, calculate networks, broadcasts, netmasks, and address ranges. +* **User-Defined Variables & Functions**: Persists variables and custom functions across sessions automatically. +* **Rich Scientific Math**: Trigonometric (supporting deg/rad/grad), hyperbolic, and standard statistical/mathematical functions. +* **Interactive CLI**: Autocompletion (TAB), history scroll, and syntax-colored output. Can also be used non-interactively via shell pipes. + +--- + +## 🚀 Installation & Setup + +Ensure you have [Go](https://go.dev/doc/install) installed. + +1. **Clone and Navigate**: + ```bash + git clone https://git.fhi.mpg.de/mike/goca.git + cd goca + ``` + +2. **Build the Binary**: + ```bash + go build -o goca + ``` + +3. **Install to Path (Optional)**: + ```bash + mv goca /usr/local/bin/ + ``` + +--- + +## 💡 Quick Start + +Simply run `goca` to start the interactive shell: + +```bash +$ ./goca +goca v1.0.15, type 'help' for examples. + +goca> 5 + 3 * 2 += 11 (0xB, 0b1011) + +goca> 100 USD to EUR += 91.42 EUR +``` + +Or pipe expressions directly from your shell: + +```bash +echo "10 mi to km" | ./goca +``` + +### 🌐 Web GUI & Server Mode +goca now features an interactive cyberpunk-styled web dashboard! Start the server by passing the `-p` or `--port` flag: + +```bash +$ ./goca -p 8800 +🌐 Go-Ca web server started on http://localhost:8800 +``` + +Open your browser and navigate to `http://localhost:8800` to access the console. The Web GUI supports: +* **Interactive Console**: Runs calculations with ANSI terminal color rendering. +* **Command History**: Navigate your previous queries using the `ArrowUp` and `ArrowDown` keys in the input box. +* **User Registry Panel**: Displays your currently defined variables and custom functions in real-time. Click any item to insert it directly into the input line. +* **Quick Reference Guide**: Direct access to mathematical, conversion, IP, and currency syntax templates. +* **State Management**: Clear the server state (variables/functions) via the `Reset State` header action. + +#### 📡 Developer REST API +Integrate the calculator engine with other services via HTTP REST endpoints: +* **Evaluate**: `GET /api/calculate?q=` - Returns `{ "output": "", "exit": }` +* **List Variables**: `GET /api/variables` - Returns a sorted JSON array of user-defined variables +* **List Functions**: `GET /api/functions` - Returns a sorted JSON array of user-defined functions +* **Reset**: `POST /api/reset` - Deletes all user-defined variables/functions + +--- + + +## 📘 Comprehensive Guide + +### 🔢 Core Math & Number Systems +goca supports standard mathematical operations, bitwise logic, and various number systems: +* **Arithmetic**: `+`, `-`, `*`, `/`, `%` (modulo), `**` or `pow(a, b)` +* **Bitwise Operations**: `&` (AND), `|` (OR), `^` (XOR), `~` (NOT), `<<` (Left Shift), `>>` (Right Shift) +* **Number Systems**: + * **Hexadecimal**: `0xFF` + * **Binary**: `0b1010` + * **Octal**: `0o77` +* **Implicit Multiplication**: `2(3 + 4)` or `2km` + +### 📐 Scientific & Mathematical Functions +A rich suite of functions is built-in: +* **Trigonometry**: `sin(x)`, `cos(x)`, `tan(x)` (supports suffixes, e.g., `sin(90 deg)` or `sin(pi rad)`) +* **Inverse Trig**: `asin(x)`, `acos(x)`, `atan(x)` (returns units, e.g., `asin(1) to deg`) +* **Hyperbolic**: `sinh(x)`, `cosh(x)`, `tanh(x)` +* **General Math**: `sqrt(x)`, `abs(x)`, `exp(x)`, `ln(x)` (natural), `log(x)` (base 10), `log2(x)` +* **Rounding**: `ceil(x)`, `floor(x)`, `round(x)` +* **Combinatorics & Stats**: `fact(x)` (factorial), `min(a, b, ...)`, `max(a, b, ...)`, `mod(a, b)` +* **Conditionals**: `if(cond, true_val, false_val)` (e.g., `if(5 > 3, 10, 20)`) +* **Constants**: `PI` and `E` + +### 💾 Variables & Custom Functions +Define and persist your own variables and functions: +* **Variables**: + * Assign: `x = 5.5` + * Reference last output: `ans` or `_` (e.g., `ans * 2`) + * List variables: `var` + * Delete variable: `unset x` (or `unset *` to clear all) +* **Custom Functions**: + * Define: `f(x, y) = x * y + 2` + * Evaluate: `f(3, 4)` (yields `14`) + * List functions: `funcs` + * Delete function: `unset f` + +All custom variables and functions are saved to `~/.goca_variables.json` and `~/.goca_functions.json` respectively, making them available in future sessions. + +### 🌐 IP & Subnet Calculations +goca includes a robust set of network utility functions: +* **Parse IP / CIDR**: `ip("192.168.1.1")`, `cidr("10.0.0.0/24")` +* **Network Address**: `network(cidr("10.0.0.50/24"))` (yields `10.0.0.0/24`) +* **Broadcast Address**: `broadcast(cidr("10.0.0.50/24"))` (yields `10.0.0.255`) +* **Netmask**: `mask(cidr("10.0.0.50/24"))` (yields `255.255.255.0`) +* **Hosts Count**: `hosts(cidr("10.0.0.0/24"))` (yields `254`) +* **IP Range**: `range(cidr("10.0.0.0/24"))` (returns string representation of start and end IPs) + +### 📏 Unit Conversions +Convert measurements using the syntax: ` to ` or ` in `. + +| Dimension | Supported Units | +| :--- | :--- | +| **Length** | `km`, `m`, `dm`, `cm`, `mm`, `um`, `nm`, `mi` (mile), `nmi` (nautical mile), `ft` (foot), `in` (inch), `yd` (yard) | +| **Mass** | `t` (tonne), `kg`, `g`, `mg`, `ct` (carat), `lb` (pound), `oz` (ounce) | +| **Time** | `yr` (year), `wk` (week), `d` (day), `h` (hour), `min` (minute), `s`, `ms`, `us`, `ns` | +| **Digital Storage** | `EB`, `PB`, `TB`, `GB`, `MB`, `KB`, `B` (binary multiplier: `1024` base) | +| **Area** | `km2` (sq. kilometer), `ha` (hectare), `acre`, `m2` (sq. meter), `cm2`, `mm2` | +| **Temperature** | `K` (Kelvin), `C` (Celsius), `F` (Fahrenheit) | +| **Angle** | `rad`, `deg` (degree), `grad` | + +**Examples**: +```text +goca> 100 mi to km += 160.9344 km + +goca> 1 GB in MB += 1024 MB + +goca> 0 C to F += 32.0000 °F +``` + +### 💱 Live Currency Exchange +goca fetches exchange rates daily from the Open Exchange Rates API and caches them locally at `~/.goca_rates.json`. + +* **Conversion**: `100 USD to EUR` or `10$ to €` +* **Change Base Currency**: `base USD` +* **List Supported Currency Codes**: `cur` +* **View Exchange Rates**: `rates` (shows core currencies relative to base) + +--- + +## 🛠️ Commands Reference + +The interactive shell supports the following commands: +* `help` - Show help information. +* `units` - List all supported measurement units. +* `rates` - Show currency exchange rates relative to the base currency. +* `cur` - List all supported currency codes. +* `var` - List all user-defined variables. +* `funcs` - List all user-defined functions. +* `unset ` - Delete a variable or custom function (or `unset *` to clear all). +* `base ` - Change base currency. +* `clear` - Clear the terminal screen. +* `exit` or `quit` - Close the session. + +--- + +## 📦 Project Architecture + +* [cli.go](file:///Users/mike/src/goca/cli.go) - Command loop, line-completion, history, and user input handler. +* [units.go](file:///Users/mike/src/goca/units.go) - Units registry and conversion metadata. +* [currency.go](file:///Users/mike/src/goca/currency.go) - Exchange rates fetcher, caching, and currency unit initialization. +* [evaluator.go](file:///Users/mike/src/goca/evaluator.go) - AST evaluator implementing all math, logic, IP, and unit conversion rules. +* [parser.go](file:///Users/mike/src/goca/parser.go) - Lexer and recursive descent parser. +* [ast.go](file:///Users/mike/src/goca/ast.go) - Abstract Syntax Tree structures for parsing expressions. +* [types.go](file:///Users/mike/src/goca/types.go) - Core types used across parsing and evaluation. +* [main_test.go](file:///Users/mike/src/goca/main_test.go) - Test suite covering math, variables, functions, and unit conversions. diff --git a/ast.go b/ast.go new file mode 100644 index 0000000..4c60cb1 --- /dev/null +++ b/ast.go @@ -0,0 +1,63 @@ +package main + +// Node represents an AST node. +type Node interface { + isNode() +} + +type Expr interface { + Node + isExpr() +} + +// BinaryExpr represents a binary operation. +type BinaryExpr struct { + Op string + Left Expr + Right Expr +} + +func (e *BinaryExpr) isNode() {} +func (e *BinaryExpr) isExpr() {} + +// UnaryExpr represents a unary operation (+, -, ~). +type UnaryExpr struct { + Op string + Expr Expr +} + +func (e *UnaryExpr) isNode() {} +func (e *UnaryExpr) isExpr() {} + +// NumberExpr represents a numeric literal. +type NumberExpr struct { + Value string // parsed as decimal later +} + +func (e *NumberExpr) isNode() {} +func (e *NumberExpr) isExpr() {} + +// IdentExpr represents an identifier (variable, unit, constant, or currency symbol). +type IdentExpr struct { + Name string +} + +func (e *IdentExpr) isNode() {} +func (e *IdentExpr) isExpr() {} + +// CallExpr represents a function call. +type CallExpr struct { + Func string + Args []Expr +} + +func (e *CallExpr) isNode() {} +func (e *CallExpr) isExpr() {} + +// StringExpr represents a string literal. +type StringExpr struct { + Value string +} + +func (e *StringExpr) isNode() {} +func (e *StringExpr) isExpr() {} diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..b5d7e3c --- /dev/null +++ b/build.sh @@ -0,0 +1,40 @@ +#!/bin/bash +set -e + +# Make sure we are in the script's directory +cd "$(dirname "$0")" + +# Read version from version.go +VERSION_LINE=$(grep "var Version =" version.go) +CURRENT_VERSION=$(echo "$VERSION_LINE" | sed -E 's/.*"([^"]+)".*/\1/') + +if [ -z "$CURRENT_VERSION" ]; then + echo "❌ Error: Could not parse version from version.go" + exit 1 +fi + +# Split version by dot +IFS='.' read -r -a VERSION_PARTS <<< "$CURRENT_VERSION" +MAJOR="${VERSION_PARTS[0]}" +MINOR="${VERSION_PARTS[1]}" +PATCH="${VERSION_PARTS[2]}" + +# Increment patch version +NEW_PATCH=$((PATCH + 1)) +NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH" + +echo "🚀 Incrementing version: $CURRENT_VERSION ➡️ $NEW_VERSION" + +# Write back to version.go using perl for portable in-place edit +perl -pi -e "s/var Version = \"$CURRENT_VERSION\"/var Version = \"$NEW_VERSION\"/" version.go + +# Update README.md version output +if [ -f README.md ]; then + perl -pi -e "s/goca v$CURRENT_VERSION/goca v$NEW_VERSION/g" README.md +fi + +# Build the project +echo "🛠️ Building goca binary..." +go build -o goca + +echo "🎉 Build successful! version: v$NEW_VERSION" diff --git a/build_releases.sh b/build_releases.sh new file mode 100755 index 0000000..76f159c --- /dev/null +++ b/build_releases.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -e + +# Make sure we are in the script's directory +cd "$(dirname "$0")" + +# Read version from version.go +VERSION_LINE=$(grep "var Version =" version.go) +VERSION=$(echo "$VERSION_LINE" | sed -E 's/.*"([^"]+)".*/\1/') + +echo "🛠️ Compiling release binaries for version v$VERSION..." +mkdir -p dist + +# macOS Intel +echo "🍏 Building macOS Intel (amd64)..." +GOOS=darwin GOARCH=amd64 go build -o dist/goca-darwin-amd64 + +# macOS Apple Silicon +echo "🍏 Building macOS Apple Silicon (arm64)..." +GOOS=darwin GOARCH=arm64 go build -o dist/goca-darwin-arm64 + +# Linux Intel +echo "🐧 Building Linux Intel (amd64)..." +GOOS=linux GOARCH=amd64 go build -o dist/goca-linux-amd64 + +# Windows Intel +echo "🏁 Building Windows Intel (amd64)..." +GOOS=windows GOARCH=amd64 go build -o dist/goca-windows-amd64.exe + +echo "🎉 All release binaries built in dist/!" diff --git a/currency.go b/currency.go new file mode 100644 index 0000000..e250c3d --- /dev/null +++ b/currency.go @@ -0,0 +1,95 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/shopspring/decimal" +) + +var baseCurrency = "EUR" +var ratesAPIURL = "https://open.er-api.com/v6/latest/EUR" + +type RatesCache struct { + Timestamp time.Time `json:"timestamp"` + Rates map[string]float64 `json:"rates"` +} + +// fetchRates downloads exchange rates from the API or loads them from a local cache +// if they are less than 24 hours old. +func fetchRates() error { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("could not find user home dir for cache: %w", err) + } + cacheFile := filepath.Join(home, ".goca_rates.json") + + var data RatesCache + useCache := false + + // Try loading from cache + file, err := os.Open(cacheFile) + if err == nil { + if err := json.NewDecoder(file).Decode(&data); err == nil { + if time.Since(data.Timestamp) < 24*time.Hour && len(data.Rates) > 0 { + useCache = true + } + } + file.Close() + } + + if !useCache { + // Fetch from API + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get(ratesAPIURL) + if err != nil { + return fmt.Errorf("failed to fetch rates from API: %w", err) + } + defer resp.Body.Close() + + var apiResp struct { + Rates map[string]float64 `json:"rates"` + } + if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { + return fmt.Errorf("failed to decode rates API response: %w", err) + } + + data = RatesCache{ + Timestamp: time.Now(), + Rates: apiResp.Rates, + } + + // Save to cache asynchronously + go func() { + file, err := os.Create(cacheFile) + if err == nil { + json.NewEncoder(file).Encode(data) + file.Close() + } + }() + } + + // Populate unit registry with rates + for code, rate := range data.Rates { + // Avoid division by zero + if rate > 0 { + factor := decimal.NewFromFloat(1.0).Div(decimal.NewFromFloat(rate)) + unitRegistry[code] = UnitInfo{factor, DimCurrency, code} + } + } + unitRegistry["EUR"] = UnitInfo{decimal.NewFromFloat(1.0), DimCurrency, "EUR"} + + return nil +} + +// eurRatesLookup returns the conversion factor of the currency to EUR. +func eurRatesLookup(code string) decimal.Decimal { + if u, ok := unitRegistry[code]; ok && u.Dimension == DimCurrency { + return u.Factor + } + return decimal.NewFromFloat(1.0) +} diff --git a/evaluator.go b/evaluator.go new file mode 100644 index 0000000..01e2d48 --- /dev/null +++ b/evaluator.go @@ -0,0 +1,568 @@ +package main + +import ( + "fmt" + "math" + "regexp" + "strconv" + "strings" + + "github.com/shopspring/decimal" +) + +var variables = make(map[string]Result) +var userFuncs = make(map[string]UserFunc) +var stringTable []string + +// evaluate evaluates a full command string, potentially handling "to/in" conversions. +func evaluate(exprStr string) (Result, string, error) { + re := regexp.MustCompile(`(?i)\s+(to|in)\s+`) + loc := re.FindStringIndex(exprStr) + + var targetUnit string + var exprToEval string + + if loc != nil { + exprToEval = exprStr[:loc[0]] + targetUnit = strings.ToUpper(strings.TrimSpace(exprStr[loc[1]:])) + } else { + exprToEval = exprStr + } + + p := NewParser(exprToEval) + ast, err := p.Parse() + if err != nil { + return Result{}, "", fmt.Errorf("parse error: %w", err) + } + + res, err := evalExpr(ast, nil) + if err != nil { + return Result{}, "", fmt.Errorf("eval error: %w", err) + } + + if targetUnit != "" { + resolvedUnit := targetUnit + switch targetUnit { + case "$": + resolvedUnit = "USD" + case "€": + resolvedUnit = "EUR" + case "£": + resolvedUnit = "GBP" + case "¥": + resolvedUnit = "JPY" + } + u, ok := unitRegistry[resolvedUnit] + if !ok { + return Result{}, "", fmt.Errorf("unknown unit: %s", targetUnit) + } + if res.Dim != u.Dimension && res.Dim != DimNone { + return Result{}, "", fmt.Errorf("cannot convert %s to %s", getDimName(res.Dim), targetUnit) + } + if res.Dim == DimTemp { + var convertedVal decimal.Decimal + switch targetUnit { + case "C", "CELSIUS": + convertedVal = res.Value.Sub(d(273.15)) + case "F", "FAHRENHEIT": + convertedVal = res.Value.Sub(d(273.15)).Mul(d(1.8)).Add(d(32)) + case "K", "KELVIN": + convertedVal = res.Value + default: + return Result{}, "", fmt.Errorf("unknown temperature unit: %s", targetUnit) + } + return Result{convertedVal, u.Dimension}, targetUnit, nil + } + return Result{res.Value.Div(u.Factor), u.Dimension}, targetUnit, nil + } + + return res, "", nil +} + +func parseIP(s string) (int64, error) { + parts := strings.Split(s, ".") + if len(parts) != 4 { + return 0, fmt.Errorf("invalid IP format") + } + var ip int64 + for i := 0; i < 4; i++ { + val, err := strconv.ParseInt(parts[i], 10, 64) + if err != nil || val < 0 || val > 255 { + return 0, fmt.Errorf("invalid IP format") + } + ip = (ip << 8) | val + } + return ip, nil +} + +func parseCIDR(s string) (int64, error) { + parts := strings.Split(s, "/") + if len(parts) != 2 { + return 0, fmt.Errorf("invalid CIDR format") + } + ip, err := parseIP(parts[0]) + if err != nil { + return 0, err + } + mask, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil || mask < 0 || mask > 32 { + return 0, fmt.Errorf("invalid CIDR mask") + } + return (ip << 8) | mask, nil +} + +func evalExpr(node Expr, locals map[string]Result) (Result, error) { + switch n := node.(type) { + case *StringExpr: + stringTable = append(stringTable, n.Value) + idx := len(stringTable) - 1 + return Result{decimal.NewFromInt(int64(idx)), DimString}, nil + + case *NumberExpr: + text := n.Value + if strings.HasPrefix(strings.ToLower(text), "0x") || strings.HasPrefix(strings.ToLower(text), "0b") || strings.HasPrefix(strings.ToLower(text), "0o") { + val, err := strconv.ParseInt(text, 0, 64) + if err != nil { + return Result{}, fmt.Errorf("invalid integer format: %w", err) + } + return Result{decimal.NewFromInt(val), DimNone}, nil + } + val, err := decimal.NewFromString(text) + if err != nil { + return Result{}, fmt.Errorf("invalid number %s: %w", text, err) + } + return Result{val, DimNone}, nil + + case *IdentExpr: + upper := strings.ToUpper(n.Name) + switch upper { + case "PI": + return Result{d(math.Pi), DimNone}, nil + case "E": + return Result{d(math.E), DimNone}, nil + } + if locals != nil { + if v, ok := locals[n.Name]; ok { + return v, nil + } + } + if v, ok := variables[n.Name]; ok { // variables are case-sensitive + return v, nil + } + // Currencies shorthand + switch n.Name { + case "$": + upper = "USD" + case "€": + upper = "EUR" + case "£": + upper = "GBP" + case "¥": + upper = "JPY" + } + u, ok := unitRegistry[upper] + if !ok { + return Result{}, fmt.Errorf("unknown unit or identifier: %s", n.Name) + } + return Result{u.Factor, u.Dimension}, nil + + case *UnaryExpr: + res, err := evalExpr(n.Expr, locals) + if err != nil { + return Result{}, err + } + if n.Op == "-" { + return Result{res.Value.Neg(), res.Dim}, nil + } else if n.Op == "~" { + val := ^res.Value.IntPart() + return Result{decimal.NewFromInt(val), DimNone}, nil + } else if n.Op == "!" { + if res.Value.IsZero() { + return Result{decimal.NewFromInt(1), DimNone}, nil + } + return Result{decimal.Zero, DimNone}, nil + } + return Result{}, fmt.Errorf("unknown unary op: %s", n.Op) + + case *BinaryExpr: + left, err := evalExpr(n.Left, locals) + if err != nil { + return Result{}, err + } + + // Handle short-circuiting or specific ops + // Right is evaluated eagerly here for most ops + right, err := evalExpr(n.Right, locals) + if err != nil { + return Result{}, err + } + + if n.Op == "implicit_mult" { + // Check if right is a temperature unit + isRightTemp := false + if right.Dim == DimTemp { + if id, ok := n.Right.(*IdentExpr); ok { + upper := strings.ToUpper(id.Name) + switch upper { + case "C", "CELSIUS": + val := left.Value.Add(d(273.15)) + return Result{val, DimTemp}, nil + case "F", "FAHRENHEIT": + val := left.Value.Sub(d(32)).Div(d(1.8)).Add(d(273.15)) + return Result{val, DimTemp}, nil + } + isRightTemp = true + } + } + if isRightTemp { + return Result{left.Value.Mul(right.Value), DimTemp}, nil // K + } + + if left.Dim != DimNone && right.Dim != DimNone { + return Result{}, fmt.Errorf("cannot multiply two units") + } + dim := left.Dim + if dim == DimNone { + dim = right.Dim + } + return Result{left.Value.Mul(right.Value), dim}, nil + } + + switch n.Op { + case "==", "!=", "<", ">", "<=", ">=": + if left.Dim != right.Dim && left.Dim != DimNone && right.Dim != DimNone { + return Result{}, fmt.Errorf("dimension mismatch in comparison") + } + cmp := left.Value.Cmp(right.Value) + var isTrue bool + switch n.Op { + case "==": isTrue = cmp == 0 + case "!=": isTrue = cmp != 0 + case "<": isTrue = cmp < 0 + case ">": isTrue = cmp > 0 + case "<=": isTrue = cmp <= 0 + case ">=": isTrue = cmp >= 0 + } + if isTrue { + return Result{decimal.NewFromInt(1), DimNone}, nil + } + return Result{decimal.Zero, DimNone}, nil + case "+", "-": + if left.Dim != right.Dim && left.Dim != DimNone && right.Dim != DimNone { + return Result{}, fmt.Errorf("dimension mismatch: cannot add %s and %s", getDimName(left.Dim), getDimName(right.Dim)) + } + dim := left.Dim + if dim == DimNone { + dim = right.Dim + } + if n.Op == "+" { + return Result{left.Value.Add(right.Value), dim}, nil + } + return Result{left.Value.Sub(right.Value), dim}, nil + case "*": + if left.Dim != DimNone && right.Dim != DimNone { + return Result{}, fmt.Errorf("cannot multiply two units") + } + dim := left.Dim + if dim == DimNone { + dim = right.Dim + } + return Result{left.Value.Mul(right.Value), dim}, nil + case "/": + if right.Value.IsZero() { + return Result{}, fmt.Errorf("division by zero") + } + if right.Dim != DimNone { + return Result{}, fmt.Errorf("cannot divide by a unit") + } + return Result{left.Value.Div(right.Value), left.Dim}, nil + case "%": + if right.Value.IsZero() { + return Result{}, fmt.Errorf("division by zero") + } + if left.Dim != DimNone || right.Dim != DimNone { + return Result{}, fmt.Errorf("modulo operator not supported on units") + } + return Result{left.Value.Mod(right.Value), DimNone}, nil + case "**": + f1, _ := left.Value.Float64() + f2, _ := right.Value.Float64() + return Result{d(math.Pow(f1, f2)), DimNone}, nil + case "|": + return Result{decimal.NewFromInt(left.Value.IntPart() | right.Value.IntPart()), DimNone}, nil + case "^": + return Result{decimal.NewFromInt(left.Value.IntPart() ^ right.Value.IntPart()), DimNone}, nil + case "&": + return Result{decimal.NewFromInt(left.Value.IntPart() & right.Value.IntPart()), DimNone}, nil + case "<<": + shift := right.Value.IntPart() + if shift < 0 { + return Result{}, fmt.Errorf("negative shift count") + } + if shift >= 64 { + return Result{decimal.Zero, DimNone}, nil + } + return Result{decimal.NewFromInt(left.Value.IntPart() << uint64(shift)), DimNone}, nil + case ">>": + shift := right.Value.IntPart() + if shift < 0 { + return Result{}, fmt.Errorf("negative shift count") + } + if shift >= 64 { + return Result{decimal.Zero, DimNone}, nil + } + return Result{decimal.NewFromInt(left.Value.IntPart() >> uint64(shift)), DimNone}, nil + } + + case *CallExpr: + fName := strings.ToLower(n.Func) + if fName == "if" { + if len(n.Args) != 3 { + return Result{}, fmt.Errorf("if expects 3 arguments: condition, true_val, false_val") + } + cond, err := evalExpr(n.Args[0], locals) + if err != nil { + return Result{}, err + } + if !cond.Value.IsZero() { + return evalExpr(n.Args[1], locals) + } + return evalExpr(n.Args[2], locals) + } + + args := make([]Result, len(n.Args)) + for i, a := range n.Args { + res, err := evalExpr(a, locals) + if err != nil { + return Result{}, err + } + args[i] = res + } + + // Helper to require exactly N arguments + requireArgs := func(count int) error { + if len(args) != count { + return fmt.Errorf("%s expects %d argument(s)", fName, count) + } + return nil + } + + switch fName { + case "ip": + if err := requireArgs(1); err != nil { return Result{}, err } + if args[0].Dim != DimString { return Result{}, fmt.Errorf("ip expects a string") } + str := stringTable[args[0].Value.IntPart()] + val, err := parseIP(str) + if err != nil { return Result{}, err } + return Result{decimal.NewFromInt(val), DimIP}, nil + + case "cidr": + if err := requireArgs(1); err != nil { return Result{}, err } + if args[0].Dim != DimString { return Result{}, fmt.Errorf("cidr expects a string") } + str := stringTable[args[0].Value.IntPart()] + val, err := parseCIDR(str) + if err != nil { return Result{}, err } + return Result{decimal.NewFromInt(val), DimCIDR}, nil + + case "network": + if err := requireArgs(1); err != nil { return Result{}, err } + if args[0].Dim != DimCIDR { return Result{}, fmt.Errorf("network expects a CIDR") } + val := args[0].Value.IntPart() + ip := val >> 8 + mask := val & 0xFF + shift := 32 - mask + network := (ip >> shift) << shift + return Result{decimal.NewFromInt(network), DimIP}, nil + + case "broadcast": + if err := requireArgs(1); err != nil { return Result{}, err } + if args[0].Dim != DimCIDR { return Result{}, fmt.Errorf("broadcast expects a CIDR") } + val := args[0].Value.IntPart() + ip := val >> 8 + mask := val & 0xFF + shift := 32 - mask + broadcast := ip | ((1 << shift) - 1) + return Result{decimal.NewFromInt(broadcast), DimIP}, nil + + case "mask": + if err := requireArgs(1); err != nil { return Result{}, err } + if args[0].Dim != DimCIDR { return Result{}, fmt.Errorf("mask expects a CIDR") } + val := args[0].Value.IntPart() + mask := val & 0xFF + shift := 32 - mask + maskIp := ((1 << mask) - 1) << shift + return Result{decimal.NewFromInt(int64(maskIp)), DimIP}, nil + + case "hosts": + if err := requireArgs(1); err != nil { return Result{}, err } + if args[0].Dim != DimCIDR { return Result{}, fmt.Errorf("hosts expects a CIDR") } + val := args[0].Value.IntPart() + mask := val & 0xFF + if mask >= 31 { + return Result{decimal.Zero, DimNone}, nil + } + hosts := (1 << (32 - mask)) - 2 + return Result{decimal.NewFromInt(int64(hosts)), DimNone}, nil + + case "range": + if err := requireArgs(1); err != nil { return Result{}, err } + if args[0].Dim != DimCIDR { return Result{}, fmt.Errorf("range expects a CIDR") } + val := args[0].Value.IntPart() + ip := val >> 8 + mask := val & 0xFF + shift := 32 - mask + network := (ip >> shift) << shift + broadcast := ip | ((1 << shift) - 1) + + var first, last int64 + if mask >= 31 { + first = network + last = broadcast + } else { + first = network + 1 + last = broadcast - 1 + } + + formatIP := func(i int64) string { + return fmt.Sprintf("%d.%d.%d.%d", (i>>24)&0xFF, (i>>16)&0xFF, (i>>8)&0xFF, i&0xFF) + } + + str := fmt.Sprintf("%s - %s", formatIP(first), formatIP(last)) + stringTable = append(stringTable, str) + return Result{decimal.NewFromInt(int64(len(stringTable) - 1)), DimString}, nil + + case "sin", "cos", "tan": + if err := requireArgs(1); err != nil { + return Result{}, err + } + if args[0].Dim != DimNone && args[0].Dim != DimAngle { + return Result{}, fmt.Errorf("%s expects a dimensionless number or angle", fName) + } + val, _ := args[0].Value.Float64() + var res float64 + if fName == "sin" { res = math.Sin(val) } + if fName == "cos" { res = math.Cos(val) } + if fName == "tan" { res = math.Tan(val) } + return Result{d(res), DimNone}, nil + + case "asin", "acos", "atan": + if err := requireArgs(1); err != nil { + return Result{}, err + } + if args[0].Dim != DimNone { + return Result{}, fmt.Errorf("%s expects a dimensionless number", fName) + } + val, _ := args[0].Value.Float64() + var res float64 + if fName == "asin" { res = math.Asin(val) } + if fName == "acos" { res = math.Acos(val) } + if fName == "atan" { res = math.Atan(val) } + return Result{d(res), DimAngle}, nil + + case "sinh", "cosh", "tanh": + if err := requireArgs(1); err != nil { + return Result{}, err + } + if args[0].Dim != DimNone { + return Result{}, fmt.Errorf("%s expects a dimensionless number", fName) + } + val, _ := args[0].Value.Float64() + var res float64 + if fName == "sinh" { res = math.Sinh(val) } + if fName == "cosh" { res = math.Cosh(val) } + if fName == "tanh" { res = math.Tanh(val) } + return Result{d(res), DimNone}, nil + + case "sqrt": + if err := requireArgs(1); err != nil { return Result{}, err } + f, _ := args[0].Value.Float64() + return Result{d(math.Sqrt(f)), DimNone}, nil + case "log": + if err := requireArgs(1); err != nil { return Result{}, err } + f, _ := args[0].Value.Float64() + return Result{d(math.Log10(f)), DimNone}, nil + case "log2": + if err := requireArgs(1); err != nil { return Result{}, err } + f, _ := args[0].Value.Float64() + return Result{d(math.Log2(f)), DimNone}, nil + case "ln": + if err := requireArgs(1); err != nil { return Result{}, err } + f, _ := args[0].Value.Float64() + return Result{d(math.Log(f)), DimNone}, nil + case "exp": + if err := requireArgs(1); err != nil { return Result{}, err } + f, _ := args[0].Value.Float64() + return Result{d(math.Exp(f)), DimNone}, nil + case "abs": + if err := requireArgs(1); err != nil { return Result{}, err } + return Result{args[0].Value.Abs(), DimNone}, nil + case "ceil": + if err := requireArgs(1); err != nil { return Result{}, err } + return Result{args[0].Value.Ceil(), DimNone}, nil + case "floor": + if err := requireArgs(1); err != nil { return Result{}, err } + return Result{args[0].Value.Floor(), DimNone}, nil + case "round": + if err := requireArgs(1); err != nil { return Result{}, err } + return Result{args[0].Value.Round(0), DimNone}, nil + case "pow": + if err := requireArgs(2); err != nil { return Result{}, err } + f1, _ := args[0].Value.Float64() + f2, _ := args[1].Value.Float64() + return Result{d(math.Pow(f1, f2)), DimNone}, nil + case "min": + if len(args) < 1 { return Result{}, fmt.Errorf("min expects at least 1 argument") } + minVal := args[0].Value + for _, arg := range args[1:] { + if arg.Value.LessThan(minVal) { minVal = arg.Value } + } + return Result{minVal, DimNone}, nil + case "max": + if len(args) < 1 { return Result{}, fmt.Errorf("max expects at least 1 argument") } + maxVal := args[0].Value + for _, arg := range args[1:] { + if arg.Value.GreaterThan(maxVal) { maxVal = arg.Value } + } + return Result{maxVal, DimNone}, nil + case "mod": + if err := requireArgs(2); err != nil { return Result{}, err } + return Result{args[0].Value.Mod(args[1].Value), DimNone}, nil + case "fact": + if err := requireArgs(1); err != nil { return Result{}, err } + f, _ := args[0].Value.Float64() + if f < 0 || f != math.Trunc(f) { + return Result{}, fmt.Errorf("fact expects a non-negative integer") + } + fact := decimal.NewFromInt(1) + valInt := args[0].Value.IntPart() + for i := int64(1); i <= valInt; i++ { + fact = fact.Mul(decimal.NewFromInt(i)) + } + return Result{fact, DimNone}, nil + default: + if uf, ok := userFuncs[n.Func]; ok { + if len(args) != len(uf.Args) { + return Result{}, fmt.Errorf("%s expects %d argument(s)", n.Func, len(uf.Args)) + } + newLocals := make(map[string]Result) + for i, argName := range uf.Args { + newLocals[argName] = args[i] + } + p := NewParser(uf.Expr) + ast, err := p.Parse() + if err != nil { + return Result{}, fmt.Errorf("error in user function %s: %w", n.Func, err) + } + return evalExpr(ast, newLocals) + } + return Result{}, fmt.Errorf("unknown function: %s", fName) + } + } + return Result{}, fmt.Errorf("unknown AST node") +} + +// evaluateExpr is a helper wrapper for tests that directly evaluate simple expressions +func evaluateExpr(expr string) (Result, error) { + res, _, err := evaluate(expr) + return res, err +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..be18d78 --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module goca + +go 1.26.1 + +require ( + aead.dev/minisign v0.2.0 // indirect + github.com/AlecAivazis/survey/v2 v2.3.7 // indirect + github.com/Masterminds/semver/v3 v3.5.0 // indirect + github.com/chzyer/readline v1.5.1 // indirect + github.com/fatih/color v1.19.0 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect + github.com/minio/selfupdate v0.6.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect + golang.org/x/text v0.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..770a0d3 --- /dev/null +++ b/go.sum @@ -0,0 +1,78 @@ +aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk= +aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ= +github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= +github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= +github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= +github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU= +github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b h1:QAqMVf3pSa6eeTsuklijukjXBlj7Es2QQplab+/RbQ4= +golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 h1:y/woIyUBFbpQGKS0u1aHF/40WUDnek3fPOyD08H5Vng= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/goca.go b/goca.go new file mode 100644 index 0000000..5ff0bac --- /dev/null +++ b/goca.go @@ -0,0 +1,642 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/chzyer/readline" +) + +var assignmentRegex = regexp.MustCompile(`^([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.*)$`) +var funcAssignmentRegex = regexp.MustCompile(`^([a-zA-Z_][a-zA-Z0-9_]*)\s*\(([^)]*)\)\s*=\s*(.*)$`) + +func isReservedName(name string) bool { + upper := strings.ToUpper(name) + if upper == "PI" || upper == "E" { return true } + lower := strings.ToLower(name) + switch lower { + case "help", "units", "rates", "base", "clear", "exit", "quit", "var", "cur", "unset", "funcs", "ans", "_": + return true + } + switch lower { + case "sin", "cos", "tan", "asin", "acos", "atan", "sinh", "cosh", "tanh", + "sqrt", "log", "log2", "ln", "exp", "abs", "ceil", "floor", "round", "pow", "min", "max", "mod", "fact", "if", + "ip", "cidr", "network", "broadcast", "mask", "hosts", "range": + return true + } + if _, ok := unitRegistry[upper]; ok { return true } + return false +} + +func loadVariables(path string) error { + file, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("could not open variables file: %w", err) + } + defer file.Close() + + decoder := json.NewDecoder(file) + if err := decoder.Decode(&variables); err != nil { + return fmt.Errorf("could not decode variables json: %w", err) + } + return nil +} + +func saveVariables(path string) error { + file, err := os.Create(path) + if err != nil { + return fmt.Errorf("could not create variables file: %w", err) + } + defer file.Close() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + if err := encoder.Encode(variables); err != nil { + return fmt.Errorf("could not encode variables json: %w", err) + } + return nil +} + +func loadUserFuncs(path string) error { + file, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("could not open functions file: %w", err) + } + defer file.Close() + + decoder := json.NewDecoder(file) + if err := decoder.Decode(&userFuncs); err != nil { + return fmt.Errorf("could not decode functions json: %w", err) + } + return nil +} + +func saveUserFuncs(path string) error { + file, err := os.Create(path) + if err != nil { + return fmt.Errorf("could not create functions file: %w", err) + } + defer file.Close() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + if err := encoder.Encode(userFuncs); err != nil { + return fmt.Errorf("could not encode functions json: %w", err) + } + return nil +} + + +func printHelp() { + fmt.Println("\033[1;34mgoca Help\033[0m") + fmt.Println("\033[1mArithmetic & Operators:\033[0m") + fmt.Println(" Basic: +, -, *, /, % (modulo)") + fmt.Println(" Power: a ** b or pow(a, b)") + fmt.Println(" Bitwise: & (AND), | (OR), ^ (XOR), ~ (NOT), << (Left Shift), >> (Right Shift)") + fmt.Println(" Logic: ==, !=, <, >, <=, >=, ! (NOT)") + fmt.Println(" Implicit: 2(3 + 4) or 2m (implicit multiplication)") + fmt.Println(" Numbers: Decimal (10), Hex (0xFF), Binary (0b1010), Octal (0o77)") + fmt.Println(" Variables: Assign (e.g. x = 5), Use 'ans' or '_' for last result") + fmt.Println(" Custom Fn: f(x, y) = x * y + 2") + fmt.Println("\033[1mScientific Functions:\033[0m") + fmt.Println(" Trig: sin(x), cos(x), tan(x) (accepts angles like 90 deg or pi rad)") + fmt.Println(" Inv Trig: asin(x), acos(x), atan(x) (returns angles, e.g., asin(1) to deg)") + fmt.Println(" Hyper: sinh(x), cosh(x), tanh(x)") + fmt.Println(" General: sqrt(x), abs(x), exp(x), ln(x) (natural), log(x) (base 10), log2(x)") + fmt.Println(" Rounding: ceil(x), floor(x), round(x)") + fmt.Println(" Stats: min(a, b, ...), max(a, b, ...), mod(a, b), fact(x) (factorial)") + fmt.Println(" Logic: if(cond, true_val, false_val)") + fmt.Println(" Constants: PI, E") + fmt.Println("\033[1mIP & Subnet:\033[0m") + fmt.Println(" Parse: ip(\"192.168.1.1\"), cidr(\"10.0.0.0/24\")") + fmt.Println(" Subnet: network(c), broadcast(c), mask(c), hosts(c), range(c)") + fmt.Println("\033[1mUnits & Conversions:\033[0m") + fmt.Println(" Syntax: to/in (e.g., 10 mi to km, 1 GB in MB)") + fmt.Println(" Types: Length, Mass, Time, Digital, Area, Temperature, Angle") + fmt.Println(" Example: 0 C to K, 90 deg to rad, 32 F to C") + fmt.Println("\033[1mCurrencies (Live Rates):\033[0m") + fmt.Println(" Usage: Exchange currency codes (e.g., 100 USD to EUR)") + fmt.Println(" Symbols: $, €, £, ¥ (e.g., 10$ to €)") + fmt.Println("\033[1mCommands:\033[0m") + fmt.Println(" help Show this help information") + fmt.Println(" units List all supported measurement units") + fmt.Println(" rates Show common currency exchange rates relative to base") + fmt.Println(" cur List all supported live currency codes") + fmt.Println(" var List all user-defined variables") + fmt.Println(" funcs List all user-defined functions") + fmt.Println(" unset Delete variable (or 'unset *' to delete all)") + fmt.Println(" base Change base currency (e.g., base USD)") + fmt.Println(" clear Clear the terminal screen") + fmt.Println(" exit/quit Exit goca") +} + +type CalcCompleter struct{} + +func (c *CalcCompleter) Do(line []rune, pos int) (newLine [][]rune, length int) { + var wordStart = pos + for wordStart > 0 { + ch := line[wordStart-1] + if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' { + wordStart-- + } else { + break + } + } + word := string(line[wordStart:pos]) + if word == "" { + return nil, 0 + } + + allCandidates := []string{ + "help", "units", "rates", "cur", "var", "funcs", "clear", "exit", "quit", "base", "unset", + "sin", "cos", "tan", "asin", "acos", "atan", "sinh", "cosh", "tanh", + "sqrt", "log", "log2", "ln", "exp", "abs", "ceil", "floor", "round", "pow", "min", "max", "mod", "fact", "if", + "ip", "cidr", "network", "broadcast", "mask", "hosts", "range", + "PI", "E", "ans", "_", + } + for u := range unitRegistry { + allCandidates = append(allCandidates, strings.ToLower(u)) + allCandidates = append(allCandidates, u) + } + + var matches []string + seen := make(map[string]bool) + for _, cand := range allCandidates { + if strings.HasPrefix(strings.ToLower(cand), strings.ToLower(word)) { + if !seen[cand] { + seen[cand] = true + matches = append(matches, cand) + } + } + } + for v := range variables { + if strings.HasPrefix(strings.ToLower(v), strings.ToLower(word)) { + if !seen[v] { + seen[v] = true + matches = append(matches, v) + } + for uf := range userFuncs { + if strings.HasPrefix(strings.ToLower(uf), strings.ToLower(word)) { + if !seen[uf] { + seen[uf] = true + matches = append(matches, uf) + } + } + } + } + } + + sort.Strings(matches) + + var ret [][]rune + for _, m := range matches { + suffix := m[len(word):] + isFunc := false + switch m { + case "sin", "cos", "tan", "asin", "acos", "atan", "sinh", "cosh", "tanh", + "sqrt", "log", "log2", "ln", "exp", "abs", "ceil", "floor", "round", "pow", "min", "max", "mod", "fact", "if", + "ip", "cidr", "network", "broadcast", "mask", "hosts", "range": + isFunc = true + } + if isFunc || func() bool { _, ok := userFuncs[m]; return ok }() { + suffix += "(" + } + ret = append(ret, []rune(suffix)) + } + + return ret, len(word) +} + +func printResult(res Result, target string) { + if res.Dim == DimString { + fmt.Printf("\033[1;33m= \"%s\"\033[0m\n", stringTable[res.Value.IntPart()]) + return + } + + val := res.Value + if target != "" { + fmt.Printf("\033[1;33m= %v %s\033[0m\n", val, target) + } else if res.Dim == DimCurrency { + rateToBase := d(1.0).Div(eurRatesLookup(baseCurrency)) + result := val.Mul(rateToBase) + + if result.IsInteger() { + i := result.IntPart() + fmt.Printf("\033[1;33m%d %s\033[0m", i, baseCurrency) + if baseCurrency == "EUR" || baseCurrency == "USD" { + fmt.Printf(" (Hex: 0x%X, Bin: 0b%b)", i, i) + } + fmt.Println() + } else { + f, _ := result.Float64() + fmt.Printf("\033[1;33m= %.4f %s\033[0m\n", f, baseCurrency) + } + } else if res.Dim == DimIP { + i := val.IntPart() + fmt.Printf("\033[1;33m= %d.%d.%d.%d\033[0m\n", (i>>24)&0xFF, (i>>16)&0xFF, (i>>8)&0xFF, i&0xFF) + } else if res.Dim == DimCIDR { + i := val.IntPart() + ipPart := i >> 8 + mask := i & 0xFF + fmt.Printf("\033[1;33m= %d.%d.%d.%d/%d\033[0m\n", (ipPart>>24)&0xFF, (ipPart>>16)&0xFF, (ipPart>>8)&0xFF, ipPart&0xFF, mask) + } else if res.Dim != DimNone { + label := "" + for _, v := range unitRegistry { + if v.Dimension == res.Dim && v.Factor.Equal(d(1.0)) { + label = v.Label + break + } + } + fmt.Printf("\033[1;33m= %v %s\033[0m\n", val, label) + } else { + if val.IsInteger() { + i := val.IntPart() + fmt.Printf("\033[1;33m%d\033[0m (0x%X, 0b%b)\n", i, i, i) + } else { + fmt.Printf("\033[1;33m= %v\033[0m\n", val) + } + } +} + +func handleLine(line string, useReadline bool, variablesFile string, functionsFile string) bool { + line = strings.TrimSpace(line) + if line == "" { + return false + } + + lower := strings.ToLower(line) + if lower == "exit" || lower == "quit" { + return true + } + if lower == "clear" { + if useReadline { + fmt.Print("\033[2J\033[H") + } + return false + } + if lower == "help" { + printHelp() + return false + } + if lower == "units" { + fmt.Println("Supported Units:") + fmt.Println(" Length: km, m, dm, cm, mm, um, nm, mi, nmi, ft, in, yd") + fmt.Println(" Mass: t, kg, g, mg, ct, lb, oz") + fmt.Println(" Time: yr, wk, d, h, min, s, ms, us, ns") + fmt.Println(" Digital: EB, PB, TB, GB, MB, KB, B") + fmt.Println(" Area: km2, ha, acre, m2, cm2, mm2") + fmt.Println(" Temperature: K, C, F") + fmt.Println(" Angle: rad, deg, grad") + return false + } + if lower == "rates" { + fmt.Printf("Exchange rates (relative to %s):\n", baseCurrency) + rateToBase := d(1.0).Div(eurRatesLookup(baseCurrency)) + for _, c := range []string{"EUR", "USD", "GBP", "JPY", "CHF", "CNY"} { + if r, ok := unitRegistry[c]; ok { + f, _ := r.Factor.Mul(rateToBase).Float64() + fmt.Printf(" 1 %s = %.4f %s\n", c, f, baseCurrency) + } + } + return false + } + if lower == "cur" { + var currencies []string + for code, u := range unitRegistry { + if u.Dimension == DimCurrency { + currencies = append(currencies, code) + } + } + sort.Strings(currencies) + fmt.Println("Supported Currencies:") + fmt.Println(" " + strings.Join(currencies, ", ")) + return false + } + if lower == "funcs" { + if len(userFuncs) == 0 { + fmt.Println("No user-defined functions.") + return false + } + fmt.Println("Defined Functions:") + var keys []string + for k := range userFuncs { + keys = append(keys, k) + } + sort.Strings(keys) + for _, name := range keys { + uf := userFuncs[name] + fmt.Printf(" %s(%s) = %s\n", name, strings.Join(uf.Args, ", "), uf.Expr) + } + return false + } + + if lower == "var" { + if len(variables) == 0 { + fmt.Println("No variables defined.") + return false + } + fmt.Println("Defined Variables:") + var keys []string + for k := range variables { + keys = append(keys, k) + } + sort.Strings(keys) + for _, name := range keys { + res := variables[name] + if res.Dim == DimCurrency { + fmt.Printf(" %s = %v (Currency)\n", name, res.Value) + } else if res.Dim != DimNone { + label := "" + for _, v := range unitRegistry { + if v.Dimension == res.Dim && v.Factor.Equal(d(1.0)) { + label = v.Label + break + } + } + fmt.Printf(" %s = %v %s\n", name, res.Value, label) + } else { + fmt.Printf(" %s = %v\n", name, res.Value) + } + } + return false + } + if strings.HasPrefix(lower, "base ") { + newBase := strings.TrimSpace(line[5:]) + switch newBase { + case "$": + newBase = "USD" + case "€": + newBase = "EUR" + case "£": + newBase = "GBP" + case "¥": + newBase = "JPY" + default: + newBase = strings.ToUpper(newBase) + } + if _, ok := unitRegistry[newBase]; ok && unitRegistry[newBase].Dimension == DimCurrency { + baseCurrency = newBase + fmt.Printf("Base set to %s\n", baseCurrency) + } else { + fmt.Printf("Error: Unknown currency %s\n", newBase) + } + return false + } + if strings.HasPrefix(lower, "unset ") { + target := strings.TrimSpace(line[6:]) + if target == "*" || strings.ToLower(target) == "all" { + if len(variables) == 0 && len(userFuncs) == 0 { + fmt.Println("No variables or functions defined.") + } else { + variables = make(map[string]Result) + if err := saveVariables(variablesFile); err != nil { + fmt.Printf("\033[1;31mWarning:\033[0m Failed to clear persisted variables: %v\n", err) + } + userFuncs = make(map[string]UserFunc) + if err := saveUserFuncs(functionsFile); err != nil { + fmt.Printf("\033[1;31mWarning:\033[0m Failed to clear persisted functions: %v\n", err) + } + fmt.Println("All variables and functions deleted.") + } + } else { + found := false + if _, ok := variables[target]; ok { + delete(variables, target) + if err := saveVariables(variablesFile); err != nil { + fmt.Printf("\033[1;31mWarning:\033[0m Failed to persist variables: %v\n", err) + } + fmt.Printf("Variable '%s' deleted.\n", target) + found = true + } + if _, ok := userFuncs[target]; ok { + delete(userFuncs, target) + if err := saveUserFuncs(functionsFile); err != nil { + fmt.Printf("\033[1;31mWarning:\033[0m Failed to persist functions: %v\n", err) + } + fmt.Printf("Function '%s' deleted.\n", target) + found = true + } + if !found { + fmt.Printf("\033[1;31mError:\033[0m Variable or function '%s' not found.\n", target) + } + } + return false + } + + if matches := funcAssignmentRegex.FindStringSubmatch(line); matches != nil { + name := matches[1] + argsStr := matches[2] + exprStr := matches[3] + + if isReservedName(name) { + fmt.Printf("\033[1;31mError:\033[0m %s is a reserved name (constant, unit, function, or command)\n", name) + return false + } + + var args []string + for _, a := range strings.Split(argsStr, ",") { + a = strings.TrimSpace(a) + if a != "" { + args = append(args, a) + } + } + + p := NewParser(exprStr) + _, err := p.Parse() + if err != nil { + fmt.Printf("\033[1;31mError:\033[0m Failed to parse function body: %v\n", err) + return false + } + + userFuncs[name] = UserFunc{Args: args, Expr: exprStr} + if err := saveUserFuncs(functionsFile); err != nil { + fmt.Printf("\033[1;31mWarning:\033[0m Failed to persist functions: %v\n", err) + } + fmt.Printf("\033[1;33mDefined function %s(%s)\033[0m\n", name, strings.Join(args, ", ")) + return false + } + + if matches := assignmentRegex.FindStringSubmatch(line); matches != nil { + name := matches[1] + expr := matches[2] + + if isReservedName(name) { + fmt.Printf("\033[1;31mError:\033[0m %s is a reserved name (constant, unit, function, or command)\n", name) + return false + } + + res, target, err := evaluate(expr) + if err != nil { + fmt.Printf("\033[1;31mError:\033[0m %v\n", err) + return false + } + + variables[name] = res + variables["ans"] = res + variables["_"] = res + if err := saveVariables(variablesFile); err != nil { + fmt.Printf("\033[1;31mWarning:\033[0m Failed to persist variables: %v\n", err) + } + + if target != "" { + fmt.Printf("\033[1;33m%s = %v %s\033[0m\n", name, res.Value, target) + } else if res.Dim == DimCurrency { + rateToBase := d(1.0).Div(eurRatesLookup(baseCurrency)) + result := res.Value.Mul(rateToBase) + if result.IsInteger() { + fmt.Printf("\033[1;33m%s = %d %s\033[0m\n", name, result.IntPart(), baseCurrency) + } else { + f, _ := result.Float64() + fmt.Printf("\033[1;33m%s = %.4f %s\033[0m\n", name, f, baseCurrency) + } + } else if res.Dim != DimNone { + label := "" + for _, v := range unitRegistry { + if v.Dimension == res.Dim && v.Factor.Equal(d(1.0)) { + label = v.Label + break + } + } + fmt.Printf("\033[1;33m%s = %v %s\033[0m\n", name, res.Value, label) + } else { + if res.Value.IsInteger() { + fmt.Printf("\033[1;33m%s = %d\033[0m\n", name, res.Value.IntPart()) + } else { + fmt.Printf("\033[1;33m%s = %v\033[0m\n", name, res.Value) + } + } + return false + } + + res, target, err := evaluate(line) + if err != nil { + fmt.Printf("\033[1;31mError:\033[0m %v\n", err) + return false + } + + variables["ans"] = res + variables["_"] = res + + printResult(res, target) + return false +} + +func main() { + initUnits() + + optY := false + newArgs := []string{os.Args[0]} + for i := 1; i < len(os.Args); i++ { + if os.Args[i] == "-y" { + optY = true + } else { + newArgs = append(newArgs, os.Args[i]) + } + } + os.Args = newArgs + + if !optY { + checkforupdate(UPDATEURL) + } + + hasArgs := len(os.Args) > 1 + + if err := fetchRates(); err != nil && !hasArgs { + fmt.Printf("\033[1;31mWarning:\033[0m Failed to fetch rates: %v\n", err) + } + + home, _ := os.UserHomeDir() + historyFile := filepath.Join(home, ".goca_history") + variablesFile := filepath.Join(home, ".goca_variables.json") + functionsFile := filepath.Join(home, ".goca_functions.json") + if err := loadUserFuncs(functionsFile); err != nil && !hasArgs { + fmt.Printf("\033[1;31mWarning:\033[0m Failed to load persisted functions: %v\n", err) + } + + if err := loadVariables(variablesFile); err != nil && !hasArgs { + fmt.Printf("\033[1;31mWarning:\033[0m Failed to load persisted variables: %v\n", err) + } + + if hasArgs { + // Check for -p or --port flag + for i := 1; i < len(os.Args); i++ { + arg := os.Args[i] + if arg == "-p" || arg == "--port" { + if i+1 < len(os.Args) { + port := os.Args[i+1] + startWebServer(port) + return + } else { + fmt.Println("\033[1;31mError:\033[0m Missing port number after -p/--port") + return + } + } + } + + input := strings.Join(os.Args[1:], " ") + handleLine(input, false, variablesFile, functionsFile) + return + } + + var useReadline = true + if fi, err := os.Stdin.Stat(); err == nil && (fi.Mode()&os.ModeCharDevice) == 0 { + useReadline = false + } + + var rl *readline.Instance + if useReadline { + var err error + rl, err = readline.NewEx(&readline.Config{ + Prompt: "\033[1;32mgoca>\033[0m ", + HistoryFile: historyFile, + InterruptPrompt: "^C", + EOFPrompt: "exit", + AutoComplete: &CalcCompleter{}, + }) + if err != nil { + useReadline = false + } else { + defer rl.Close() + } + } + + if useReadline { + fmt.Printf("\033[1;34mgoca v%s\033[0m, type 'help' for examples.\n", Version) + } + + var scanner *bufio.Scanner + if !useReadline { + scanner = bufio.NewScanner(os.Stdin) + } + + for { + var line string + if useReadline { + var err error + line, err = rl.Readline() + if err != nil { + break + } + } else { + if !scanner.Scan() { + break + } + line = scanner.Text() + } + if handleLine(line, useReadline, variablesFile, functionsFile) { + break + } + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..e698e47 --- /dev/null +++ b/main_test.go @@ -0,0 +1,295 @@ +package main + +import ( + "encoding/json" + "math" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/shopspring/decimal" +) + +func TestEvaluateExpr(t *testing.T) { + initUnits() + tests := []struct { + expr string + wantVal float64 + wantErr bool + }{ + // Basic arithmetic + {"2 + 3", 5, false}, + {"2 - 3", -1, false}, + {"2 * 3", 6, false}, + {"6 / 3", 2, false}, + {"2 * 3 + 4", 10, false}, + {"2 + 3 * 4", 14, false}, + {"(2 + 3) * 4", 20, false}, + {"-5 + 10", 5, false}, + {"-(5 + 10)", -15, false}, + + // Division by zero + {"6 / 0", 0, true}, + + // Hex, binary, and octal literals + {"0xFF", 255, false}, + {"0b1010", 10, false}, + {"0o77", 63, false}, + {"0XF", 15, false}, + {"0B11", 3, false}, + {"0O10", 8, false}, + + // Exponentiation (**) + {"2 ** 3", 8, false}, + {"2 ** 3 ** 2", 512, false}, + {"2 * 3 ** 2", 18, false}, + {"3 ** 2 * 2", 18, false}, + + // Scientific functions + {"abs(-5)", 5, false}, + {"sqrt(9)", 3, false}, + {"ceil(4.2)", 5, false}, + {"floor(4.8)", 4, false}, + {"round(4.5)", 5, false}, + {"sin(0)", 0, false}, + {"cos(0)", 1, false}, + {"tan(0)", 0, false}, + {"asin(0)", 0, false}, + {"acos(1)", 0, false}, + {"atan(0)", 0, false}, + {"sinh(0)", 0, false}, + {"cosh(0)", 1, false}, + {"tanh(0)", 0, false}, + {"ln(E)", 1, false}, + {"log(10)", 1, false}, + {"log2(8)", 3, false}, + {"exp(1)", math.E, false}, + {"pow(2, 3)", 8, false}, + {"pow(2 + 1, 3)", 27, false}, + {"min(5, 2, 7)", 2, false}, + {"max(1, 8, 3)", 8, false}, + {"mod(10, 3)", 1, false}, + {"fact(5)", 120, false}, + {"fact(0)", 1, false}, + + // Function error cases + {"fact(-1)", 0, true}, + {"fact(3.5)", 0, true}, + + // Constants + {"PI", math.Pi, false}, + {"E", math.E, false}, + + // Bitwise + {"5 & 3", 1, false}, // 101 & 011 = 001 + {"5 | 3", 7, false}, // 101 | 011 = 111 + {"5 ^ 3", 6, false}, // 101 ^ 011 = 110 + {"~5", -6, false}, + {"1 << 8", 256, false}, + {"1024 >> 2", 256, false}, + {"1 << 2 + 3", 32, false}, // precedence test: 1 << (2 + 3) + {"1 << -1", 0, true}, // error: negative shift count + + // Modulo operator + {"10 % 3", 1, false}, + {"10 % 0", 0, true}, + + // Implicit multiplication with units and parentheses + {"2m", 2, false}, + {"2 m", 2, false}, + {"2km", 2000, false}, + {"2(3 + 4)", 14, false}, + {"(2 + 3)(4 + 5)", 45, false}, + } + + for _, tt := range tests { + res, err := evaluateExpr(tt.expr) + if (err != nil) != tt.wantErr { + t.Errorf("evaluateExpr(%q) error = %v, wantErr %v", tt.expr, err, tt.wantErr) + continue + } + if !tt.wantErr { + f, _ := res.Value.Float64() + if math.Abs(f-tt.wantVal) > 1e-9 { + t.Errorf("evaluateExpr(%q) = %v, want %v", tt.expr, f, tt.wantVal) + } + } + } +} + +func TestEvaluateConversions(t *testing.T) { + initUnits() + unitRegistry["USD"] = UnitInfo{Factor: decimal.NewFromFloat(0.85), Dimension: DimCurrency, Label: "USD"} + unitRegistry["EUR"] = UnitInfo{Factor: decimal.NewFromFloat(1.0), Dimension: DimCurrency, Label: "EUR"} + + tests := []struct { + expr string + wantVal float64 + wantUnit string + wantErr bool + }{ + // Unit conversions (Length) + {"10 mi to km", 16.09344, "KM", false}, + {"10 dm to m", 1, "M", false}, + {"1e9 nm to m", 1, "M", false}, + {"1000 um to mm", 1, "MM", false}, + {"1 nmi to m", 1852, "M", false}, + + // Unit conversions (Mass) + {"1000g in kg", 1, "KG", false}, + {"1 t to kg", 1000, "KG", false}, + {"5 ct to g", 1, "G", false}, + + // Unit conversions (Time) + {"1h to min", 60, "MIN", false}, + {"1e9 ns to s", 1, "S", false}, + {"1 wk to d", 7, "D", false}, + {"1 yr to d", 365, "D", false}, + + // Unit conversions (Digital) + {"1 GB in MB", 1024, "MB", false}, + {"1 PB to TB", 1024, "TB", false}, + {"1 EB to PB", 1024, "PB", false}, + + // Unit conversions (Area) + {"1 ha to m2", 10000, "M2", false}, + {"10000 m2 in ha", 1, "HA", false}, + + // Currency conversions + {"10 USD to EUR", 8.5, "EUR", false}, + {"10$ to EUR", 8.5, "EUR", false}, + {"10 USD to €", 8.5, "€", false}, + {"10$ to €", 8.5, "€", false}, + + // Temperature conversions + {"0 C to K", 273.15, "K", false}, + {"100 C in F", 212, "F", false}, + {"32 F to C", 0, "C", false}, + {"293.15 K to C", 20, "C", false}, + {"20 C + 5 K", 298.15, "", false}, // evaluates internally to 298.15 K + {"20 CELSIUS to KELVIN", 293.15, "KELVIN", false}, + + // Angle conversions & trigonometry + {"90 deg to rad", math.Pi / 2, "RAD", false}, + {"180 deg in rad", math.Pi, "RAD", false}, + {"pi rad to deg", 180, "DEG", false}, + {"sin(90 deg)", 1, "", false}, + {"cos(pi rad)", -1, "", false}, + {"asin(1) to deg", 90, "DEG", false}, + {"asin(0.5) to deg", 30, "DEG", false}, + + // Error cases + {"10m to kg", 0, "", true}, + {"10 USD to m", 0, "", true}, + {"10m + 5kg", 0, "", true}, + {"20 C to m", 0, "", true}, + {"10m / 2s", 0, "", true}, + {"10 / (2m)", 0, "", true}, + {"10m % 3", 0, "", true}, + {"sin(10m)", 0, "", true}, + {"asin(10m)", 0, "", true}, + } + + for _, tt := range tests { + res, target, err := evaluate(tt.expr) + if (err != nil) != tt.wantErr { + t.Errorf("evaluate(%q) error = %v, wantErr %v", tt.expr, err, tt.wantErr) + continue + } + if !tt.wantErr { + if target != tt.wantUnit { + t.Errorf("evaluate(%q) target unit = %q, want %q", tt.expr, target, tt.wantUnit) + } + f, _ := res.Value.Float64() + if math.Abs(f-tt.wantVal) > 1e-9 { + t.Errorf("evaluate(%q) = %v, want %v", tt.expr, f, tt.wantVal) + } + } + } +} + +func TestVariableAssignments(t *testing.T) { + initUnits() + variables = make(map[string]Result) + + variables["x"] = Result{d(5), DimNone} + res, err := evaluateExpr("x * 2") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !res.Value.Equal(d(10)) { + t.Errorf("x * 2 = %v, want 10", res.Value) + } + + variables["y"] = Result{d(10), DimLength} + val, target, err := evaluate("y to cm") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if target != "CM" || !val.Value.Equal(d(1000)) { + t.Errorf("evaluate('y to cm') = %v %s, want 1000 CM", val.Value, target) + } +} + +func TestFetchRatesMock(t *testing.T) { + initUnits() + mockResponse := struct { + Rates map[string]float64 `json:"rates"` + }{ + Rates: map[string]float64{ + "USD": 1.1, + "GBP": 0.85, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(mockResponse) + })) + defer server.Close() + + oldRatesAPIURL := ratesAPIURL + ratesAPIURL = server.URL + defer func() { ratesAPIURL = oldRatesAPIURL }() + + delete(unitRegistry, "USD") + delete(unitRegistry, "GBP") + + // remove old cache file to force fetch + home, _ := os.UserHomeDir() + os.Remove(filepath.Join(home, ".goca_rates.json")) + + err := fetchRates() + if err != nil { + t.Fatalf("fetchRates failed: %v", err) + } + + usd, ok := unitRegistry["USD"] + if !ok { + t.Fatalf("USD was not added to unitRegistry") + } + expectedUSD := d(1.0).Div(d(1.1)) + if !usd.Factor.Equal(expectedUSD) { + t.Errorf("USD factor = %v, want %v", usd.Factor, expectedUSD) + } +} + +func FuzzEvaluateExpr(f *testing.F) { + initUnits() + + // Add seed corpus + f.Add("2 + 3") + f.Add("sin(pi rad)") + f.Add("10 km to mi") + f.Add("0xFF + 0b10") + f.Add("x = 5") + f.Add("1 << 4") + f.Add("((2+3)*4)/5") + + f.Fuzz(func(t *testing.T, expr string) { + // Fuzzing ensures no panics occur on malformed input + evaluate(expr) + }) +} diff --git a/notarize.sh b/notarize.sh new file mode 100755 index 0000000..540939f --- /dev/null +++ b/notarize.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# Script to sign and notarize goca Darwin binaries + +set -e + +# Make sure we are in the script's directory +cd "$(dirname "$0")" + +# Validate required variables +if [ -z "$APPLE_ID" ] || [ -z "$APPLE_PASSWORD" ] || [ -z "$TEAM_ID" ]; then + echo "❌ Error: Please set the required environment variables: APPLE_ID, APPLE_PASSWORD, and TEAM_ID." + echo "" + echo "Example usage:" + echo " export APPLE_ID='mw@pstbx.org'" + echo " export APPLE_PASSWORD='your-app-specific-password'" + echo " export TEAM_ID='26TUG6V94S'" + echo " ./notarize.sh" + exit 1 +fi + +SIGNING_IDENTITY="Developer ID Application: Mike Wesemann ($TEAM_ID)" + +# Verify binaries exist +if [ ! -f "dist/goca-darwin-amd64" ] || [ ! -f "dist/goca-darwin-arm64" ]; then + echo "❌ Error: Darwin binaries not found in 'dist/'. Run ./build_releases.sh first." + exit 1 +fi + +echo "🔐 Step 1: Codesigning Darwin binaries..." +codesign --force --options runtime --timestamp --sign "$SIGNING_IDENTITY" dist/goca-darwin-amd64 +codesign --force --options runtime --timestamp --sign "$SIGNING_IDENTITY" dist/goca-darwin-arm64 + +echo "📦 Step 2: Packaging binaries into ZIP archives..." +mkdir -p dist/notarize +rm -f dist/notarize/*.zip +ditto -c -k --keepParent dist/goca-darwin-amd64 dist/notarize/goca-darwin-amd64.zip +ditto -c -k --keepParent dist/goca-darwin-arm64 dist/notarize/goca-darwin-arm64.zip + +echo "🚀 Step 3: Submitting macOS Intel binary (amd64) to Apple Notarization..." +xcrun notarytool submit dist/notarize/goca-darwin-amd64.zip \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_PASSWORD" \ + --team-id "$TEAM_ID" \ + --wait + +echo "🚀 Step 4: Submitting macOS Apple Silicon binary (arm64) to Apple Notarization..." +xcrun notarytool submit dist/notarize/goca-darwin-arm64.zip \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_PASSWORD" \ + --team-id "$TEAM_ID" \ + --wait + +echo "🎉 Notarization successfully completed for both macOS binaries!" diff --git a/parser.go b/parser.go new file mode 100644 index 0000000..6721ea4 --- /dev/null +++ b/parser.go @@ -0,0 +1,341 @@ +package main + +import ( + "fmt" + "strings" + "text/scanner" +) + +// Parser parses a string into an AST. +type Parser struct { + s scanner.Scanner +} + +func NewParser(expr string) *Parser { + // Preprocess multi-char operators to single chars so the scanner reads them easily + expr = strings.ReplaceAll(expr, "**", "\x01") + expr = strings.ReplaceAll(expr, "<<", "\x02") + expr = strings.ReplaceAll(expr, ">>", "\x03") + expr = strings.ReplaceAll(expr, "==", "\x04") + expr = strings.ReplaceAll(expr, "!=", "\x05") + expr = strings.ReplaceAll(expr, "<=", "\x06") + expr = strings.ReplaceAll(expr, ">=", "\x07") + p := &Parser{} + p.s.Init(strings.NewReader(expr)) + p.s.Mode = scanner.ScanInts | scanner.ScanFloats | scanner.ScanIdents | scanner.ScanStrings + p.s.Error = func(s *scanner.Scanner, msg string) {} + return p +} + +func (p *Parser) skipWhitespace() { + for { + ch := p.s.Peek() + if ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' { + p.s.Next() + continue + } + break + } +} + +// Parse parses the entire expression. +func (p *Parser) Parse() (Expr, error) { + expr, err := p.parseExpression() + if err != nil { + return nil, err + } + p.skipWhitespace() + if p.s.Peek() != scanner.EOF { + return nil, fmt.Errorf("unexpected trailing characters") + } + return expr, nil +} + +func (p *Parser) parseExpression() (Expr, error) { + return p.parseEquality() +} + +func (p *Parser) parseEquality() (Expr, error) { + res, err := p.parseRelational() + if err != nil { + return nil, err + } + for { + p.skipWhitespace() + ch := p.s.Peek() + if ch == '\x04' { + p.s.Next() + rhs, err := p.parseRelational() + if err != nil { + return nil, err + } + res = &BinaryExpr{Op: "==", Left: res, Right: rhs} + } else if ch == '\x05' { + p.s.Next() + rhs, err := p.parseRelational() + if err != nil { + return nil, err + } + res = &BinaryExpr{Op: "!=", Left: res, Right: rhs} + } else { + return res, nil + } + } +} + +func (p *Parser) parseRelational() (Expr, error) { + res, err := p.parseXor() + if err != nil { + return nil, err + } + for { + p.skipWhitespace() + ch := p.s.Peek() + if ch == '\x06' { + p.s.Next() + rhs, err := p.parseXor() + if err != nil { + return nil, err + } + res = &BinaryExpr{Op: "<=", Left: res, Right: rhs} + } else if ch == '\x07' { + p.s.Next() + rhs, err := p.parseXor() + if err != nil { + return nil, err + } + res = &BinaryExpr{Op: ">=", Left: res, Right: rhs} + } else if ch == '<' { + p.s.Next() + rhs, err := p.parseXor() + if err != nil { + return nil, err + } + res = &BinaryExpr{Op: "<", Left: res, Right: rhs} + } else if ch == '>' { + p.s.Next() + rhs, err := p.parseXor() + if err != nil { + return nil, err + } + res = &BinaryExpr{Op: ">", Left: res, Right: rhs} + } else { + return res, nil + } + } +} + +func (p *Parser) parseXor() (Expr, error) { + res, err := p.parseAnd() + if err != nil { + return nil, err + } + for { + p.skipWhitespace() + if p.s.Peek() == '|' || p.s.Peek() == '^' { + op := string(p.s.Next()) + rhs, err := p.parseAnd() + if err != nil { + return nil, err + } + res = &BinaryExpr{Op: op, Left: res, Right: rhs} + } else { + return res, nil + } + } +} + +func (p *Parser) parseAnd() (Expr, error) { + res, err := p.parseShift() + if err != nil { + return nil, err + } + for { + p.skipWhitespace() + if p.s.Peek() == '&' { + p.s.Next() + rhs, err := p.parseShift() + if err != nil { + return nil, err + } + res = &BinaryExpr{Op: "&", Left: res, Right: rhs} + } else { + return res, nil + } + } +} + +func (p *Parser) parseShift() (Expr, error) { + res, err := p.parseSum() + if err != nil { + return nil, err + } + for { + p.skipWhitespace() + ch := p.s.Peek() + if ch == '\x02' { + p.s.Next() + rhs, err := p.parseSum() + if err != nil { + return nil, err + } + res = &BinaryExpr{Op: "<<", Left: res, Right: rhs} + } else if ch == '\x03' { + p.s.Next() + rhs, err := p.parseSum() + if err != nil { + return nil, err + } + res = &BinaryExpr{Op: ">>", Left: res, Right: rhs} + } else { + return res, nil + } + } +} + +func (p *Parser) parseSum() (Expr, error) { + res, err := p.parseTerm() + if err != nil { + return nil, err + } + for { + p.skipWhitespace() + ch := p.s.Peek() + if ch == '+' || ch == '-' { + op := string(p.s.Next()) + rhs, err := p.parseTerm() + if err != nil { + return nil, err + } + res = &BinaryExpr{Op: op, Left: res, Right: rhs} + } else { + return res, nil + } + } +} + +func (p *Parser) parseTerm() (Expr, error) { + res, err := p.parsePower() + if err != nil { + return nil, err + } + for { + p.skipWhitespace() + ch := p.s.Peek() + if ch == '*' || ch == '/' || ch == '%' { + op := string(p.s.Next()) + rhs, err := p.parsePower() + if err != nil { + return nil, err + } + res = &BinaryExpr{Op: op, Left: res, Right: rhs} + } else if (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || ch == '$' || ch == '€' || ch == '£' || ch == '¥' || ch == '(' { + // Implicit multiplication + rhs, err := p.parseFactor() + if err != nil { + return nil, err + } + res = &BinaryExpr{Op: "implicit_mult", Left: res, Right: rhs} + } else { + return res, nil + } + } +} + +func (p *Parser) parsePower() (Expr, error) { + res, err := p.parseFactor() + if err != nil { + return nil, err + } + p.skipWhitespace() + if p.s.Peek() == '\x01' { + p.s.Next() + rhs, err := p.parsePower() + if err != nil { + return nil, err + } + res = &BinaryExpr{Op: "**", Left: res, Right: rhs} + } + return res, nil +} + +func (p *Parser) parseFactor() (Expr, error) { + tok := p.s.Scan() + switch tok { + case '(': + res, err := p.parseExpression() + if err != nil { + return nil, err + } + if p.s.Scan() != ')' { + return nil, fmt.Errorf("missing )") + } + return res, nil + case '-': + res, err := p.parseFactor() + if err != nil { + return nil, err + } + return &UnaryExpr{Op: "-", Expr: res}, nil + case '~', '!': + op := string(tok) + res, err := p.parseFactor() + if err != nil { + return nil, err + } + return &UnaryExpr{Op: op, Expr: res}, nil + case '$': + return &IdentExpr{Name: "$"}, nil + case '€': + return &IdentExpr{Name: "€"}, nil + case '£': + return &IdentExpr{Name: "£"}, nil + case '¥': + return &IdentExpr{Name: "¥"}, nil + case scanner.String: + text := p.s.TokenText() + text = text[1 : len(text)-1] + return &StringExpr{Value: text}, nil + case scanner.Int, scanner.Float, scanner.Ident: + text := p.s.TokenText() + if tok == scanner.Ident { + p.skipWhitespace() + if p.s.Peek() == '(' { + p.s.Next() // consume '(' + var args []Expr + for { + p.skipWhitespace() + if p.s.Peek() == ')' { + p.s.Next() + break + } + arg, err := p.parseExpression() + if err != nil { + return nil, err + } + args = append(args, arg) + p.skipWhitespace() + nextChar := p.s.Peek() + if nextChar == ',' { + p.s.Next() + continue + } else if nextChar == ')' { + p.s.Next() + break + } else { + return nil, fmt.Errorf("missing ) or comma in function arguments") + } + } + return &CallExpr{Func: text, Args: args}, nil + } + return &IdentExpr{Name: text}, nil + } + return &NumberExpr{Value: text}, nil + default: + txt := p.s.TokenText() + if tok == scanner.EOF { + return nil, fmt.Errorf("unexpected end of expression") + } + return nil, fmt.Errorf("unexpected %s", txt) + } +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..30f58bc --- /dev/null +++ b/types.go @@ -0,0 +1,40 @@ +package main + +import "github.com/shopspring/decimal" + +// Dimension represents the physical dimension of a unit. +type Dimension int + +const ( + DimNone Dimension = iota + DimCurrency + DimLength + DimMass + DimTime + DimDigital + DimArea + DimTemp + DimAngle + DimIP + DimCIDR + DimString +) + +// Result holds the evaluated numeric value and its dimension. +type Result struct { + Value decimal.Decimal + Dim Dimension +} + +// UnitInfo stores the conversion factor (to the pivot unit) and its dimension. +type UnitInfo struct { + Factor decimal.Decimal + Dimension Dimension + Label string +} + +// UserFunc represents a user-defined function. +type UserFunc struct { + Args []string `json:"args"` + Expr string `json:"expr"` +} diff --git a/units.go b/units.go new file mode 100644 index 0000000..d56af6d --- /dev/null +++ b/units.go @@ -0,0 +1,108 @@ +package main + +import ( + "math" + + "github.com/shopspring/decimal" +) + +var unitRegistry = make(map[string]UnitInfo) + +func d(f float64) decimal.Decimal { + return decimal.NewFromFloat(f) +} + +// initUnits populates the unitRegistry with supported units. +func initUnits() { + // Length (Pivot: meter) + unitRegistry["M"] = UnitInfo{d(1.0), DimLength, "m"} + unitRegistry["DM"] = UnitInfo{d(0.1), DimLength, "dm"} + unitRegistry["CM"] = UnitInfo{d(0.01), DimLength, "cm"} + unitRegistry["MM"] = UnitInfo{d(0.001), DimLength, "mm"} + unitRegistry["UM"] = UnitInfo{d(1e-6), DimLength, "um"} + unitRegistry["NM"] = UnitInfo{d(1e-9), DimLength, "nm"} + unitRegistry["KM"] = UnitInfo{d(1000.0), DimLength, "km"} + unitRegistry["IN"] = UnitInfo{d(0.0254), DimLength, "in"} + unitRegistry["FT"] = UnitInfo{d(0.3048), DimLength, "ft"} + unitRegistry["YD"] = UnitInfo{d(0.9144), DimLength, "yd"} + unitRegistry["MI"] = UnitInfo{d(1609.344), DimLength, "mi"} + unitRegistry["NMI"] = UnitInfo{d(1852.0), DimLength, "nmi"} + + // Mass (Pivot: gram) + unitRegistry["G"] = UnitInfo{d(1.0), DimMass, "g"} + unitRegistry["MG"] = UnitInfo{d(0.001), DimMass, "mg"} + unitRegistry["KG"] = UnitInfo{d(1000.0), DimMass, "kg"} + unitRegistry["T"] = UnitInfo{d(1e6), DimMass, "t"} + unitRegistry["CT"] = UnitInfo{d(0.2), DimMass, "ct"} + unitRegistry["LB"] = UnitInfo{d(453.592), DimMass, "lb"} + unitRegistry["OZ"] = UnitInfo{d(28.3495), DimMass, "oz"} + + // Time (Pivot: second) + unitRegistry["S"] = UnitInfo{d(1.0), DimTime, "s"} + unitRegistry["MS"] = UnitInfo{d(0.001), DimTime, "ms"} + unitRegistry["US"] = UnitInfo{d(1e-6), DimTime, "us"} + unitRegistry["NS"] = UnitInfo{d(1e-9), DimTime, "ns"} + unitRegistry["MIN"] = UnitInfo{d(60.0), DimTime, "min"} + unitRegistry["H"] = UnitInfo{d(3600.0), DimTime, "h"} + unitRegistry["D"] = UnitInfo{d(86400.0), DimTime, "d"} + unitRegistry["WK"] = UnitInfo{d(604800.0), DimTime, "wk"} + unitRegistry["YR"] = UnitInfo{d(31536000.0), DimTime, "yr"} + + // Digital (Pivot: byte) + unitRegistry["B"] = UnitInfo{d(1.0), DimDigital, "B"} + unitRegistry["KB"] = UnitInfo{d(1024.0), DimDigital, "KB"} + unitRegistry["MB"] = UnitInfo{d(1024.0 * 1024.0), DimDigital, "MB"} + unitRegistry["GB"] = UnitInfo{d(1024.0 * 1024.0 * 1024.0), DimDigital, "GB"} + unitRegistry["TB"] = UnitInfo{d(1024.0 * 1024.0 * 1024.0 * 1024.0), DimDigital, "TB"} + unitRegistry["PB"] = UnitInfo{d(1024.0 * 1024.0 * 1024.0 * 1024.0 * 1024.0), DimDigital, "PB"} + unitRegistry["EB"] = UnitInfo{d(1024.0 * 1024.0 * 1024.0 * 1024.0 * 1024.0 * 1024.0), DimDigital, "EB"} + + // Area (Pivot: square meter) + unitRegistry["M2"] = UnitInfo{d(1.0), DimArea, "m²"} + unitRegistry["CM2"] = UnitInfo{d(1e-4), DimArea, "cm²"} + unitRegistry["MM2"] = UnitInfo{d(1e-6), DimArea, "mm²"} + unitRegistry["KM2"] = UnitInfo{d(1e6), DimArea, "km²"} + unitRegistry["HA"] = UnitInfo{d(10000.0), DimArea, "ha"} + unitRegistry["ACRE"] = UnitInfo{d(4046.8564), DimArea, "acre"} + + // Temperature (Pivot: Kelvin) + unitRegistry["K"] = UnitInfo{d(1.0), DimTemp, "K"} + unitRegistry["C"] = UnitInfo{d(1.0), DimTemp, "°C"} + unitRegistry["F"] = UnitInfo{d(1.0), DimTemp, "°F"} + unitRegistry["KELVIN"] = UnitInfo{d(1.0), DimTemp, "K"} + unitRegistry["CELSIUS"] = UnitInfo{d(1.0), DimTemp, "°C"} + unitRegistry["FAHRENHEIT"] = UnitInfo{d(1.0), DimTemp, "°F"} + + // Angle (Pivot: Radian) + unitRegistry["RAD"] = UnitInfo{d(1.0), DimAngle, "rad"} + unitRegistry["DEG"] = UnitInfo{d(math.Pi / 180.0), DimAngle, "deg"} + unitRegistry["GRAD"] = UnitInfo{d(math.Pi / 200.0), DimAngle, "grad"} + unitRegistry["RADIAN"] = UnitInfo{d(1.0), DimAngle, "rad"} + unitRegistry["DEGREE"] = UnitInfo{d(math.Pi / 180.0), DimAngle, "deg"} + unitRegistry["DEGREES"] = UnitInfo{d(math.Pi / 180.0), DimAngle, "deg"} + unitRegistry["GRADIAN"] = UnitInfo{d(math.Pi / 200.0), DimAngle, "grad"} +} + +// getDimName returns a human-readable string for a dimension. +func getDimName(d Dimension) string { + switch d { + case DimCurrency: + return "Currency" + case DimLength: + return "Length" + case DimMass: + return "Mass" + case DimTime: + return "Time" + case DimDigital: + return "Digital Storage" + case DimArea: + return "Area" + case DimTemp: + return "Temperature" + case DimAngle: + return "Angle" + default: + return "Number" + } +} diff --git a/update.go b/update.go new file mode 100644 index 0000000..1ae9911 --- /dev/null +++ b/update.go @@ -0,0 +1,127 @@ +package main + +import ( + "bufio" + "fmt" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/AlecAivazis/survey/v2" + "github.com/AlecAivazis/survey/v2/terminal" + "github.com/Masterminds/semver/v3" + "github.com/fatih/color" + "github.com/minio/selfupdate" +) + +var UPDATEURL = "http://gozilla.fhi.mpg.de/goca" + +// Color function variables matching tools.go in dns +var Crb func(...interface{}) string = color.New(color.Bold, color.FgRed).SprintFunc() +var Cgb func(...interface{}) string = color.New(color.Bold, color.FgGreen).SprintFunc() +var Cwb func(...interface{}) string = color.New(color.Bold, color.FgWhite).SprintFunc() + +func SF(format string, a ...any) string { + return fmt.Sprintf(format, a...) +} + +func PE(msg ...string) (n int, err error) { + if len(msg) == 2 { + return fmt.Fprintf(os.Stdout, "%s: %s (%s)\n", Crb("ERROR"), Cwb(msg[0]), msg[1]) + } + return fmt.Fprintf(os.Stdout, "%s: %s\n", Crb("ERROR"), Cwb(msg[0])) +} + +func PO(msg ...string) (n int, err error) { + if len(msg) == 2 { + return fmt.Fprintf(os.Stdout, "%s: %s (%s)\n", Cgb("OK"), Cwb(msg[0]), msg[1]) + } + return fmt.Fprintf(os.Stdout, "%s: %s\n", Cgb("OK"), Cwb(msg[0])) +} + +func checkforupdate(URL string) { // ----------------------------------------------------- check for new version + prg := prgname() + resp, err := http.Get(URL + "/version.txt") + if err == nil { + scanner := bufio.NewScanner(resp.Body) + if scanner.Scan() { + lversion := strings.TrimSpace(scanner.Text()) + sv_version, err := semver.NewVersion(Version) + if err == nil { + sv_lversion, err := semver.NewVersion(lversion) + if err == nil { + if sv_lversion.GreaterThan(sv_version) { + ans := Yesno(SF("new '%s' version found (%s -> %s), update now?", + prg, sv_version, sv_lversion), true, false) + if ans { + updateurl := SF("%s/%s_%s_%s_%s", URL, prg, lversion, runtime.GOOS, runtime.GOARCH) + + if err := doupdate(updateurl); err != nil { + PE(SF("Update failed: %v\n", err)) + os.Exit(1) + } + PO("Update successful!", "please run your last command again") + os.Exit(0) + } + } + } + } + } + resp.Body.Close() + } +} + +func doupdate(url string) error { // ----------------------------------------------------------------- do update + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("server returned status: %v", resp.Status) + } + err = selfupdate.Apply(resp.Body, selfupdate.Options{}) + if err != nil { + return err + } + return nil +} + +func Yesno(msg string, def bool, overwrite bool) bool { // -------------------------- AlecAivazis/survey: yes/no + if overwrite { + return true + } + + var err error + tmp := "" + if def { + err = survey.AskOne(&survey.Select{Message: msg, Options: []string{"Yes", "No"}}, &tmp) + } else { + err = survey.AskOne(&survey.Select{Message: msg, Options: []string{"No", "Yes"}}, &tmp) + } + + if err != nil { + if err == terminal.InterruptErr { + fmt.Fprintln(os.Stdout, Crb("Interrupted.")) + os.Exit(0) + } + } + + if tmp == "Yes" { + return true + } else { + return false + } +} + +func prgname() string { // ---------------------------------------------------------------- program name + exepath, err := os.Executable() + if err != nil { + PE(SF("Error getting executable path: %s", err)) + return "" + } + exename := filepath.Base(exepath) + return exename +} diff --git a/version.go b/version.go new file mode 100644 index 0000000..d2f7d4a --- /dev/null +++ b/version.go @@ -0,0 +1,6 @@ +package main + +// Version is the current version of the application. +// It is automatically incremented by the build script. +var Version = "1.1.1" + diff --git a/web.go b/web.go new file mode 100644 index 0000000..202ca90 --- /dev/null +++ b/web.go @@ -0,0 +1,869 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "sync" +) + +// Mutex to serialize calculation evaluations and capture output safely +var evalMu sync.Mutex + +type CalculateResponse struct { + Output string `json:"output"` + Exit bool `json:"exit"` +} + +type VariableResponse struct { + Name string `json:"name"` + Value string `json:"value"` + Dim string `json:"dim"` +} + +type FunctionResponse struct { + Name string `json:"name"` + Args []string `json:"args"` + Expr string `json:"expr"` +} + +// evaluateExpressionSafe redirects stdout for the duration of the evaluation to capture output safely +func evaluateExpressionSafe(line string, variablesFile string, functionsFile string) (string, bool) { + evalMu.Lock() + defer evalMu.Unlock() + + // Keep backup of original stdout + oldStdout := os.Stdout + defer func() { os.Stdout = oldStdout }() + + // Create pipe to intercept stdout + r, w, err := os.Pipe() + if err != nil { + return "Error capturing stdout", false + } + os.Stdout = w + + // Run evaluation (prints to stdout) + exit := handleLine(line, false, variablesFile, functionsFile) + + // Close write end of pipe and read captured output + w.Close() + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + _ = r.Close() + + return buf.String(), exit +} + +// startWebServer boots the HTTP server serving the Web GUI and APIs +func startWebServer(port string) { + home, _ := os.UserHomeDir() + variablesFile := filepath.Join(home, ".goca_variables.json") + functionsFile := filepath.Join(home, ".goca_functions.json") + + // Load persisted variables and custom functions + _ = loadUserFuncs(functionsFile) + _ = loadVariables(variablesFile) + + // Fetch current currency rates in the background to ensure units work + _ = fetchRates() + + // Serve the interactive web dashboard + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" && r.URL.Path != "/index.html" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + html := strings.ReplaceAll(indexHTML, "{{VERSION}}", Version) + _, _ = w.Write([]byte(html)) + }) + + // Evaluation endpoint + http.HandleFunc("/api/calculate", func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query().Get("q") + if query == "" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(CalculateResponse{Output: "Empty expression", Exit: false}) + return + } + + output, exit := evaluateExpressionSafe(query, variablesFile, functionsFile) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(CalculateResponse{ + Output: output, + Exit: exit, + }) + }) + + // List variables endpoint + http.HandleFunc("/api/variables", func(w http.ResponseWriter, r *http.Request) { + evalMu.Lock() + defer evalMu.Unlock() + + list := []VariableResponse{} + for k, v := range variables { + // Skip the internal answer variables from bloating list + if k == "ans" || k == "_" { + continue + } + list = append(list, VariableResponse{ + Name: k, + Value: fmt.Sprintf("%v", v.Value), + Dim: fmt.Sprintf("%v", v.Dim), + }) + } + sort.Slice(list, func(i, j int) bool { + return list[i].Name < list[j].Name + }) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(list) + }) + + // List custom functions endpoint + http.HandleFunc("/api/functions", func(w http.ResponseWriter, r *http.Request) { + evalMu.Lock() + defer evalMu.Unlock() + + list := []FunctionResponse{} + for k, v := range userFuncs { + list = append(list, FunctionResponse{ + Name: k, + Args: v.Args, + Expr: v.Expr, + }) + } + sort.Slice(list, func(i, j int) bool { + return list[i].Name < list[j].Name + }) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(list) + }) + + // Clear state endpoint + http.HandleFunc("/api/reset", func(w http.ResponseWriter, r *http.Request) { + evalMu.Lock() + defer evalMu.Unlock() + + variables = make(map[string]Result) + _ = saveVariables(variablesFile) + + userFuncs = make(map[string]UserFunc) + _ = saveUserFuncs(functionsFile) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"success"}`)) + }) + + fmt.Printf("Go-Ca web server started on http://localhost:%s\n", port) + if err := http.ListenAndServe(":"+port, nil); err != nil { + fmt.Printf("❌ Error starting web server: %v\n", err) + } +} + +// Beautiful cyber-retro dashboard HTML layout string +const indexHTML = ` + + + + + Go-Ca | Scientific Cyber Calculator + + + + + + + + +
+ + +
+
+
+ SERVER CONSOLE ACTIVE +
+ + +
+
+ +
+ +
+
+ + 12:00:00 +
+ +
+
+ goca {{VERSION}}, type 'help' for examples. +
+
+
+ +
+ goca> + + +
+
+
+ + + +`