initial commit [141.14.140.180,mike]
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
._*
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
|
||||
# Binaries
|
||||
goca
|
||||
dist/
|
||||
bin/
|
||||
@@ -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.
|
||||
|
||||
[](https://go.dev/)
|
||||
[](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=<expression>` - Returns `{ "output": "<result string>", "exit": <bool> }`
|
||||
* **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: `<value> <unit> to <unit>` or `<value> <unit> in <unit>`.
|
||||
|
||||
| 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 <name>` - Delete a variable or custom function (or `unset *` to clear all).
|
||||
* `base <currency>` - 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.
|
||||
@@ -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() {}
|
||||
@@ -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"
|
||||
Executable
+30
@@ -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/!"
|
||||
+95
@@ -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)
|
||||
}
|
||||
+568
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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: <value> <unit> to/in <unit> (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 <v> Delete variable <v> (or 'unset *' to delete all)")
|
||||
fmt.Println(" base <C> 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
|
||||
}
|
||||
}
|
||||
}
|
||||
+295
@@ -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)
|
||||
})
|
||||
}
|
||||
Executable
+53
@@ -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!"
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Go-Ca | Scientific Cyber Calculator</title>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;600;800&family=Share+Tech+Mono&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--color-cyan: #06b6d4;
|
||||
--color-pink: #f43f5e;
|
||||
--color-yellow: #fbbf24;
|
||||
--color-green: #10b981;
|
||||
--color-blue: #3b82f6;
|
||||
--color-purple: #8b5cf6;
|
||||
--color-red: #ef4444;
|
||||
--color-orange: #ea580c;
|
||||
|
||||
--bg-dark: #080c14;
|
||||
--bg-card: #0f172a;
|
||||
--bg-input: #020617;
|
||||
|
||||
--border-neon: rgba(6, 182, 212, 0.18);
|
||||
--border-warm: rgba(234, 88, 12, 0.22);
|
||||
|
||||
--font-display: 'Outfit', -apple-system, sans-serif;
|
||||
--font-mono: 'Share Tech Mono', monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-dark);
|
||||
background-image: radial-gradient(circle at top, #0f1c30 0%, #05080e 100%);
|
||||
color: #f8fafc;
|
||||
font-family: var(--font-display);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Top Navigation Header */
|
||||
header {
|
||||
background-color: rgba(15, 23, 42, 0.5);
|
||||
border-bottom: 1px solid var(--border-neon);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 0.85rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
text-shadow: 0 0 10px rgba(6, 182, 212, 0.35);
|
||||
}
|
||||
|
||||
.header-logo span {
|
||||
background: linear-gradient(135deg, var(--color-cyan) 0%, #a5f3fc 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.status-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
color: var(--color-green);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--color-green);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 10px var(--color-green);
|
||||
animation: pulse 1.8s infinite;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
background: rgba(244, 63, 94, 0.08);
|
||||
border: 1px solid var(--color-pink);
|
||||
border-radius: 4px;
|
||||
color: var(--color-pink);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
padding: 0.35rem 0.75rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.reset-btn:hover {
|
||||
background: rgba(244, 63, 94, 0.2);
|
||||
box-shadow: 0 0 10px rgba(244, 63, 94, 0.25);
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 1.5rem 2rem;
|
||||
}
|
||||
|
||||
/* Column 1: Terminal Console Window */
|
||||
.console-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: rgba(2, 6, 23, 0.65);
|
||||
border: 1px solid var(--border-neon);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.4), inset 0 0 15px rgba(255, 255, 255, 0.01);
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.console-header {
|
||||
background-color: rgba(15, 23, 42, 0.4);
|
||||
border-bottom: 1px solid rgba(6, 182, 212, 0.1);
|
||||
padding: 0.6rem 1.25rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-cyan);
|
||||
}
|
||||
|
||||
.console-terminal {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.05) transparent;
|
||||
}
|
||||
|
||||
.terminal-welcome {
|
||||
color: var(--color-blue);
|
||||
margin-bottom: 1rem;
|
||||
border-left: 2px solid var(--color-blue);
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.history-query {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.history-output {
|
||||
padding-left: 0.85rem;
|
||||
}
|
||||
|
||||
/* Interactive Command Prompt Line */
|
||||
.prompt-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--bg-input);
|
||||
border-top: 1px solid rgba(6, 182, 212, 0.1);
|
||||
padding: 1rem 1.25rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.prompt-symbol {
|
||||
color: var(--color-green);
|
||||
font-family: var(--font-mono);
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.prompt-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #f1f5f9;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.05rem;
|
||||
caret-color: var(--color-green);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background-color: var(--color-cyan);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #020617;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
padding: 0.5rem 1rem;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
box-shadow: 0 0 15px var(--color-cyan);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Keyframe animations */
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 5px var(--color-green);
|
||||
opacity: 0.8;
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 15px var(--color-green);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 5px var(--color-green);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Help manual formatting */
|
||||
.help-card {
|
||||
background-color: rgba(15, 23, 42, 0.4);
|
||||
border: 1px solid rgba(6, 182, 212, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.help-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
color: var(--color-cyan);
|
||||
margin-bottom: 1rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid rgba(6, 182, 212, 0.2);
|
||||
padding-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.help-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.help-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.help-group-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
color: var(--color-yellow);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.help-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: rgba(2, 6, 23, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.03);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.help-item:hover {
|
||||
background-color: rgba(6, 182, 212, 0.05);
|
||||
border-color: rgba(6, 182, 212, 0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.help-syntax {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-cyan);
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.10rem;
|
||||
}
|
||||
|
||||
.help-desc {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* Responsive styling for tablet and mobile devices */
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-grid {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.status-indicator span {
|
||||
display: none; /* Hide status text on small screens */
|
||||
}
|
||||
|
||||
.status-panel {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
padding: 0.35rem 0.6rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.console-terminal {
|
||||
padding: 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.prompt-container {
|
||||
padding: 0.75rem 0.85rem;
|
||||
}
|
||||
|
||||
.prompt-input {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 0.45rem 0.85rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.help-card {
|
||||
padding: 0.85rem;
|
||||
}
|
||||
.help-title {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.help-grid {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.help-item {
|
||||
padding: 0.45rem 0.6rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="header-logo">
|
||||
<span>Go-Ca</span> SYSTEM
|
||||
</div>
|
||||
|
||||
<div class="status-panel">
|
||||
<div class="status-indicator">
|
||||
<div class="status-dot"></div>
|
||||
<span>SERVER CONSOLE ACTIVE</span>
|
||||
</div>
|
||||
|
||||
<button class="reset-btn" id="btn-reset" title="Deletes all user variables & functions">RESET STATE</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<!-- Calculator shell terminal -->
|
||||
<div class="console-card">
|
||||
<div class="console-header">
|
||||
<span></span>
|
||||
<span id="session-time">12:00:00</span>
|
||||
</div>
|
||||
|
||||
<div class="console-terminal" id="terminal-scroller">
|
||||
<div class="terminal-welcome">
|
||||
goca {{VERSION}}, type 'help' for examples.
|
||||
</div>
|
||||
<div id="terminal-history"></div>
|
||||
</div>
|
||||
|
||||
<div class="prompt-container">
|
||||
<span class="prompt-symbol">goca></span>
|
||||
<input type="text" id="cmd-input" class="prompt-input" autocomplete="off" autofocus placeholder="Type expression (e.g. 10 mi to km)">
|
||||
<button class="submit-btn" id="btn-submit">RUN</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Local input history list tracker
|
||||
const cmdInput = document.getElementById('cmd-input');
|
||||
const btnSubmit = document.getElementById('btn-submit');
|
||||
const btnReset = document.getElementById('btn-reset');
|
||||
const historyContainer = document.getElementById('terminal-history');
|
||||
const terminalScroller = document.getElementById('terminal-scroller');
|
||||
|
||||
const inputHistory = [];
|
||||
let historyIdx = -1;
|
||||
|
||||
// Tick clock
|
||||
setInterval(function() {
|
||||
const d = new Date();
|
||||
document.getElementById('session-time').textContent = d.toTimeString().split(' ')[0];
|
||||
}, 1000);
|
||||
|
||||
// Trigger evaluate
|
||||
btnSubmit.addEventListener('click', runCommand);
|
||||
cmdInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
runCommand();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (inputHistory.length > 0) {
|
||||
historyIdx = Math.min(historyIdx + 1, inputHistory.length - 1);
|
||||
cmdInput.value = inputHistory[inputHistory.length - 1 - historyIdx];
|
||||
}
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (historyIdx > 0) {
|
||||
historyIdx--;
|
||||
cmdInput.value = inputHistory[inputHistory.length - 1 - historyIdx];
|
||||
} else if (historyIdx === 0) {
|
||||
historyIdx = -1;
|
||||
cmdInput.value = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
btnReset.addEventListener('click', function() {
|
||||
if (confirm("Clear all user-defined variables and custom functions?")) {
|
||||
fetch('/api/reset', { method: 'POST' })
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function() {
|
||||
appendHistoryLine('unset *', '<span style="color: var(--color-pink);">All variables and functions deleted.</span>');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function runCommand() {
|
||||
const line = cmdInput.value.trim();
|
||||
if (!line) return;
|
||||
|
||||
inputHistory.push(line);
|
||||
historyIdx = -1;
|
||||
cmdInput.value = '';
|
||||
|
||||
if (line.toLowerCase() === 'clear') {
|
||||
historyContainer.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.toLowerCase() === 'help') {
|
||||
appendHistoryLine(line, getInteractiveHelpHTML());
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/api/calculate?q=' + encodeURIComponent(line))
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(data) {
|
||||
const formattedOutput = ansiToHtml(data.output);
|
||||
appendHistoryLine(line, formattedOutput);
|
||||
})
|
||||
.catch(function(err) {
|
||||
appendHistoryLine(line, '<span style="color: var(--color-red);">Error: Failed to connect to server.</span>');
|
||||
});
|
||||
}
|
||||
|
||||
function appendHistoryLine(query, output) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'history-item';
|
||||
div.innerHTML = '<div class="history-query">goca> ' + escapeHtml(query) + '</div>' +
|
||||
'<div class="history-output">' + output + '</div>';
|
||||
historyContainer.appendChild(div);
|
||||
terminalScroller.scrollTop = terminalScroller.scrollHeight;
|
||||
}
|
||||
|
||||
// Convert Go CLI ANSI color tags to styled HTML spans
|
||||
function ansiToHtml(text) {
|
||||
return text
|
||||
.replace(/\u001b\[1;33m/g, '<span style="color: var(--color-yellow); font-weight: bold;">')
|
||||
.replace(/\u001b\[1;31m/g, '<span style="color: var(--color-red); font-weight: bold;">')
|
||||
.replace(/\u001b\[1;32m/g, '<span style="color: var(--color-green); font-weight: bold;">')
|
||||
.replace(/\u001b\[1;34m/g, '<span style="color: var(--color-cyan); font-weight: bold;">') // Cyan style logo
|
||||
.replace(/\u001b\[1;30m/g, '<span style="color: #475569;">')
|
||||
.replace(/\u001b\[1m/g, '<span style="font-weight: bold;">')
|
||||
.replace(/\u001b\[0m/g, '</span>')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
|
||||
function insertSample(expr) {
|
||||
cmdInput.value = expr;
|
||||
cmdInput.focus();
|
||||
}
|
||||
|
||||
function getInteractiveHelpHTML() {
|
||||
var html = '';
|
||||
html += '<div class="help-card">';
|
||||
html += ' <div class="help-title">Go-Ca Interactive Manual</div>';
|
||||
html += ' <div class="help-grid">';
|
||||
|
||||
html += ' <div class="help-group">';
|
||||
html += ' <div class="help-group-title">Arithmetic & Operators</div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'5 + 3 * 2\')">';
|
||||
html += ' <div class="help-syntax">5 + 3 * 2</div>';
|
||||
html += ' <div class="help-desc">Basic: +, -, *, /, % (modulo)</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'10 ** 3\')">';
|
||||
html += ' <div class="help-syntax">10 ** 3</div>';
|
||||
html += ' <div class="help-desc">Power: a ** b or pow(a, b)</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'0xFF & 0b1010\')">';
|
||||
html += ' <div class="help-syntax">0xFF & 0b1010</div>';
|
||||
html += ' <div class="help-desc">Bitwise: &, |, ^, ~, << (Left Shift), >> (Right Shift)</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'10 > 5\')">';
|
||||
html += ' <div class="help-syntax">10 > 5</div>';
|
||||
html += ' <div class="help-desc">Logic: ==, !=, <, >, <=, >=, ! (NOT)</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'2(3 + 4)\')">';
|
||||
html += ' <div class="help-syntax">2(3 + 4)</div>';
|
||||
html += ' <div class="help-desc">Implicit: 2(3 + 4) or 2m (implicit multiplication)</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'0xFF\')">';
|
||||
html += ' <div class="help-syntax">0xFF</div>';
|
||||
html += ' <div class="help-desc">Numbers: Decimal (10), Hex (0xFF), Binary (0b1010), Octal (0o77)</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'x = 5.5\')">';
|
||||
html += ' <div class="help-syntax">x = 5.5</div>';
|
||||
html += ' <div class="help-desc">Variables: Assign (e.g. x = 5), Use \'ans\' or \'_\' for last result</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'f(x, y) = x * y + 2\')">';
|
||||
html += ' <div class="help-syntax">f(x, y) = x * y + 2</div>';
|
||||
html += ' <div class="help-desc">Custom Fn: f(x, y) = x * y + 2</div>';
|
||||
html += ' </div>';
|
||||
html += ' </div>';
|
||||
|
||||
html += ' <div class="help-group">';
|
||||
html += ' <div class="help-group-title">Scientific Functions</div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'sin(90 deg)\')">';
|
||||
html += ' <div class="help-syntax">sin(90 deg)</div>';
|
||||
html += ' <div class="help-desc">Trig: sin(x), cos(x), tan(x) (accepts angles like 90 deg or pi rad)</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'asin(1) to deg\')">';
|
||||
html += ' <div class="help-syntax">asin(1) to deg</div>';
|
||||
html += ' <div class="help-desc">Inv Trig: asin(x), acos(x), atan(x) (returns angles, e.g. asin(1) to deg)</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'sinh(1)\')">';
|
||||
html += ' <div class="help-syntax">sinh(1)</div>';
|
||||
html += ' <div class="help-desc">Hyper: sinh(x), cosh(x), tanh(x)</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'sqrt(16)\')">';
|
||||
html += ' <div class="help-syntax">sqrt(16)</div>';
|
||||
html += ' <div class="help-desc">General: sqrt(x), abs(x), exp(x), ln(x) (natural), log(x) (base 10), log2(x)</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'round(5.5)\')">';
|
||||
html += ' <div class="help-syntax">round(5.5)</div>';
|
||||
html += ' <div class="help-desc">Rounding: ceil(x), floor(x), round(x)</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'min(10, 20, 5)\')">';
|
||||
html += ' <div class="help-syntax">min(10, 20, 5)</div>';
|
||||
html += ' <div class="help-desc">Stats: min(a, b, ...), max(a, b, ...), mod(a, b), fact(x) (factorial)</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'if(5 > 3, PI, E)\')">';
|
||||
html += ' <div class="help-syntax">if(5 > 3, PI, E)</div>';
|
||||
html += ' <div class="help-desc">Logic: if(cond, true_val, false_val)</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'PI\')">';
|
||||
html += ' <div class="help-syntax">PI</div>';
|
||||
html += ' <div class="help-desc">Constants: PI, E</div>';
|
||||
html += ' </div>';
|
||||
html += ' </div>';
|
||||
|
||||
html += ' <div class="help-group">';
|
||||
html += ' <div class="help-group-title">IP & Subnet</div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'ip(\\"192.168.1.1\\")\')">';
|
||||
html += ' <div class="help-syntax">ip("192.168.1.1")</div>';
|
||||
html += ' <div class="help-desc">Parse: ip("192.168.1.1"), cidr("10.0.0.0/24")</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'network(cidr(\\"10.0.0.0/24\\"))\')">';
|
||||
html += ' <div class="help-syntax">network(cidr("10.0.0.0/24"))</div>';
|
||||
html += ' <div class="help-desc">Subnet: network(c), broadcast(c), mask(c), hosts(c), range(c)</div>';
|
||||
html += ' </div>';
|
||||
html += ' </div>';
|
||||
|
||||
html += ' <div class="help-group">';
|
||||
html += ' <div class="help-group-title">Units & Conversions</div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'10 mi to km\')">';
|
||||
html += ' <div class="help-syntax">10 mi to km</div>';
|
||||
html += ' <div class="help-desc">Syntax: <value> <unit> to/in <unit> (e.g. 10 mi to km, 1 GB in MB)</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'units\')">';
|
||||
html += ' <div class="help-syntax">units</div>';
|
||||
html += ' <div class="help-desc">Types: Length, Mass, Time, Digital, Area, Temperature, Angle</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'0 C to K\')">';
|
||||
html += ' <div class="help-syntax">0 C to K</div>';
|
||||
html += ' <div class="help-desc">Example: 0 C to K, 90 deg to rad, 32 F to C</div>';
|
||||
html += ' </div>';
|
||||
html += ' </div>';
|
||||
|
||||
html += ' <div class="help-group">';
|
||||
html += ' <div class="help-group-title">Currencies (Live Rates)</div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'100 USD to EUR\')">';
|
||||
html += ' <div class="help-syntax">100 USD to EUR</div>';
|
||||
html += ' <div class="help-desc">Usage: Exchange currency codes (e.g. 100 USD to EUR)</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'10$ to €\')">';
|
||||
html += ' <div class="help-syntax">10$ to €</div>';
|
||||
html += ' <div class="help-desc">Symbols: $, €, £, ¥ (e.g. 10$ to €)</div>';
|
||||
html += ' </div>';
|
||||
html += ' </div>';
|
||||
|
||||
html += ' <div class="help-group">';
|
||||
html += ' <div class="help-group-title">Commands</div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'help\')">';
|
||||
html += ' <div class="help-syntax">help</div>';
|
||||
html += ' <div class="help-desc">help: Show this help information</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'units\')">';
|
||||
html += ' <div class="help-syntax">units</div>';
|
||||
html += ' <div class="help-desc">units: List all supported measurement units</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'rates\')">';
|
||||
html += ' <div class="help-syntax">rates</div>';
|
||||
html += ' <div class="help-desc">rates: Show currency exchange rates relative to base</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'cur\')">';
|
||||
html += ' <div class="help-syntax">cur</div>';
|
||||
html += ' <div class="help-desc">cur: List all supported live currency codes</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'var\')">';
|
||||
html += ' <div class="help-syntax">var</div>';
|
||||
html += ' <div class="help-desc">var: List all user-defined variables</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'funcs\')">';
|
||||
html += ' <div class="help-syntax">funcs</div>';
|
||||
html += ' <div class="help-desc">funcs: List all user-defined custom functions</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'unset x\')">';
|
||||
html += ' <div class="help-syntax">unset x</div>';
|
||||
html += ' <div class="help-desc">unset <v>: Delete variable <v> (or \'unset *\' to delete all)</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'base USD\')">';
|
||||
html += ' <div class="help-syntax">base USD</div>';
|
||||
html += ' <div class="help-desc">base <C>: Change base currency (e.g. base USD)</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'clear\')">';
|
||||
html += ' <div class="help-syntax">clear</div>';
|
||||
html += ' <div class="help-desc">clear: Clear the terminal screen</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="help-item" onclick="insertSample(\'exit\')">';
|
||||
html += ' <div class="help-syntax">exit</div>';
|
||||
html += ' <div class="help-desc">exit/quit: Exit goca</div>';
|
||||
html += ' </div>';
|
||||
html += ' </div>';
|
||||
|
||||
html += ' </div>';
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
Reference in New Issue
Block a user