Files
2026-08-12 16:23:52 +02:00

689 lines
20 KiB
Go

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")
fmt.Println("\033[1mUpdates:\033[0m")
fmt.Println(" goca --version Print the version")
fmt.Println(" goca --check-update Look for a newer release")
fmt.Println(" goca --update Download and install the newest release")
fmt.Println(" goca looks for a new release once a day, in the background, and says so on")
fmt.Println(" stderr. GOCA_NO_UPDATE_CHECK=1 or -y turns that off.")
}
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
}
// printUpdateHint puts the note of the daily look on stderr, dimmed, so that it
// never lands in a pipe that only expects the result.
func printUpdateHint(hint string) {
if hint != "" {
fmt.Fprintf(os.Stderr, "\033[2m%s\033[0m\n", hint)
}
}
func main() {
// The options come before everything else: --update-refresh is the
// background run and must touch neither units nor rates, and --version has
// to answer at once — the updater probes the downloaded binary with it.
optY := false
newArgs := []string{os.Args[0]}
for i := 1; i < len(os.Args); i++ {
switch os.Args[i] {
case "-y":
optY = true
case "--version":
fmt.Printf("goca %s\n", Version)
return
case "--update":
if err := selfUpdate.install(os.Stdout); err != nil {
fmt.Fprintf(os.Stderr, "goca: %v\n", err)
os.Exit(1)
}
return
case "--check-update":
if err := selfUpdate.check(os.Stdout); err != nil {
fmt.Fprintf(os.Stderr, "goca: %v\n", err)
os.Exit(1)
}
return
case updateRefreshFlag: // the background run, not in the help
selfUpdate.refresh()
return
default:
newArgs = append(newArgs, os.Args[i])
}
}
os.Args = newArgs
initUnits()
// Costs nothing: the hint comes from the note in the cache, and the asking
// happens once a day at most, in the background. -y keeps it quiet.
updateHint := ""
if !optY {
updateHint = selfUpdate.daily()
}
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]
printUpdateHint(updateHint)
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)
printUpdateHint(updateHint)
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)
printUpdateHint(updateHint)
}
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
}
}
if !useReadline { // piped input: the hint comes at the end, not before it
printUpdateHint(updateHint)
}
}