cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Formulas in a locale's syntax, translated at the edges

Dami committed 6cefe51
formula.Localize and Delocalize swap the decimal point, the argument
separator (; where the decimal separator is a comma) and an array row's
separator (\), leaving strings, sheet names and references alone. Each
swap is one byte for one, so parse errors and the caret keep their
positions; unfinished formulas translate too.
commit
6cefe51acdeb1b886503d96662be919e5057adfb
parent
d4f901117717210274ce48f4d1e1c9c2f4ed42de
author
Dami <dami.etoile@gmail.com>
2 files changed, +203 -0
Ainternal/formula/locale.go129++++++++++++++++++++++++++++++
Ainternal/formula/locale_test.go74+++++++++++++++++
A internal/formula/locale.go +129 -0
@@ -0,0 +1,129 @@
1+package formula2+3+import "github.com/FelineStateMachine/012/internal/locale"4+5+// Formulas are stored and parsed in en-US's syntax: 1.5 for a number, ,6+// between arguments and ; between an array's rows. A locale whose7+// decimal separator is a comma writes 1,5, puts ; between arguments and8+// \ between the values of an array's row, as Sheets does: =ROUND(1,5; 0)9+// and {1\2;3\4}. Function names stay in English. Localize and10+// Delocalize translate at the edges, where formulas are shown and typed;11+// each separator is one byte in both, so a position in one is the same12+// position in the other (a parse error's, the caret's).13+14+// Localize writes a formula (or any part of one) as stored in loc's15+// syntax. Strings and sheet names are left alone.16+func Localize(src string, loc *locale.Locale) string {17+ return translate(src, loc, true)18+}19+20+// Delocalize reads a formula typed in loc's syntax into the syntax it is21+// stored and parsed in. It works on unfinished formulas too.22+func Delocalize(src string, loc *locale.Locale) string {23+ return translate(src, loc, false)24+}25+26+// SameSyntax reports whether loc writes formulas as they are stored.27+func SameSyntax(loc *locale.Locale) bool {28+ return loc == nil || loc.Decimal == '.' && loc.ArgSep() == ',' && loc.ColSep() == ','29+}30+31+// separators are the characters a translation swaps, from and to.32+type separators struct {33+ dec, arg, col byte // in the syntax read34+ toDec, toArg, toCol byte // in the syntax written35+}36+37+func translate(src string, loc *locale.Locale, out bool) string {38+ if SameSyntax(loc) {39+ return src40+ }41+ sep := separators{dec: '.', arg: ',', col: ',', toDec: loc.Decimal, toArg: loc.ArgSep(), toCol: loc.ColSep()}42+ if !out {43+ sep = separators{dec: loc.Decimal, arg: loc.ArgSep(), col: loc.ColSep(), toDec: '.', toArg: ',', toCol: ','}44+ }45+ b := []byte(src)46+ var open []byte // the ( and { around the position, innermost last47+ for i := 0; i < len(b); {48+ switch c := b[i]; {49+ case c == '"' || c == '\'':50+ i = skipQuoted(b, i)51+ case c == '@' || isIdentStart(c):52+ i = skipIdent(b, i+1)53+ case isDigit(c) || c == sep.dec && i+1 < len(b) && isDigit(b[i+1]):54+ i = sep.number(b, i)55+ case c == '(' || c == '{':56+ open = append(open, c)57+ i++58+ case c == ')' || c == '}':59+ if len(open) > 0 {60+ open = open[:len(open)-1]61+ }62+ i++63+ default:64+ inArray := len(open) > 0 && open[len(open)-1] == '{'65+ switch {66+ case inArray && c == sep.col:67+ b[i] = sep.toCol68+ case !inArray && c == sep.arg:69+ b[i] = sep.toArg70+ }71+ i++72+ }73+ }74+ return string(b)75+}76+77+// skipQuoted skips a string or quoted sheet name starting at i, with its78+// quote doubled inside; an unclosed one runs to the end.79+func skipQuoted(b []byte, i int) int {80+ q := b[i]81+ for i++; i < len(b); i++ {82+ if b[i] != q {83+ continue84+ }85+ if i+1 < len(b) && b[i+1] == q {86+ i++87+ continue88+ }89+ return i + 190+ }91+ return len(b)92+}93+94+// skipIdent skips the rest of a reference, name or function name.95+func skipIdent(b []byte, i int) int {96+ for i < len(b) && (isIdentPart(b[i]) || b[i] >= 0x80) {97+ i++98+ }99+ return i100+}101+102+// number rewrites the decimal separator of the number at i, returning103+// where it ends: digits, one decimal separator, and an exponent.104+func (sep separators) number(b []byte, i int) int {105+ point := false106+ for ; i < len(b); i++ {107+ switch c := b[i]; {108+ case isDigit(c):109+ case c == sep.dec && !point && (i+1 >= len(b) || b[i+1] != sep.dec):110+ b[i], point = sep.toDec, true111+ case (c == 'e' || c == 'E') && exponentAt(b, i+1):112+ i++113+ if b[i] == '+' || b[i] == '-' {114+ i++115+ }116+ default:117+ return i118+ }119+ }120+ return i121+}122+123+// exponentAt reports whether an exponent's sign or digits start at i.124+func exponentAt(b []byte, i int) bool {125+ if i < len(b) && (b[i] == '+' || b[i] == '-') {126+ i++127+ }128+ return i < len(b) && isDigit(b[i])129+}
A internal/formula/locale_test.go +74 -0
@@ -0,0 +1,74 @@
1+package formula2+3+import (4+ "testing"5+6+ "github.com/FelineStateMachine/012/internal/locale"7+)8+9+func mustLocale(t *testing.T, tag string) *locale.Locale {10+ t.Helper()11+ l, ok := locale.Lookup(tag)12+ if !ok {13+ t.Fatalf("no locale %s", tag)14+ }15+ return l16+}17+18+// TestFormulaLocaleRoundTrip types formulas in each locale, stores them19+// canonically, parses them, and prints them back in the locale.20+func TestFormulaLocaleRoundTrip(t *testing.T) {21+ cases := []struct{ tag, typed, stored string }{22+ {"de-DE", "=ROUND(1,5; 0)", "=ROUND(1.5, 0)"},23+ {"de-DE", "=SUM(A1:A3;0,25)*2", "=SUM(A1:A3,0.25)*2"},24+ {"de-DE", `=IF(A1>1,5;"a,b;c";'Q3; x'!B2)`, `=IF(A1>1.5,"a,b;c",'Q3; x'!B2)`},25+ {"de-DE", "=SUM({1\\2,5;3\\4})", "=SUM({1,2.5;3,4})"},26+ {"de-DE", "=1,5E-3+,5", "=1.5E-3+.5"},27+ {"de-DE", "=SUM(Sheet2!A1..B3;Tax.Rate)", "=SUM(Sheet2!A1..B3,Tax.Rate)"},28+ {"fr-FR", "=SUM(1,25; 2)", "=SUM(1.25, 2)"},29+ {"pt-BR", "=IF(A1;1,5;2,5)", "=IF(A1,1.5,2.5)"},30+ {"en-GB", "=ROUND(1.5, 0)", "=ROUND(1.5, 0)"},31+ {"ja-JP", "={1,2;3,4}", "={1,2;3,4}"},32+ {"en-US", "=ROUND(1.5, 0)", "=ROUND(1.5, 0)"},33+ }34+ for _, c := range cases {35+ l := mustLocale(t, c.tag)36+ stored := Delocalize(c.typed, l)37+ if stored != c.stored {38+ t.Errorf("%s: %q stored as %q, want %q", c.tag, c.typed, stored, c.stored)39+ continue40+ }41+ if len(stored) != len(c.typed) {42+ t.Errorf("%s: %q changed length", c.tag, c.typed)43+ }44+ if back := Localize(stored, l); back != c.typed {45+ t.Errorf("%s: %q shown as %q, want %q", c.tag, stored, back, c.typed)46+ }47+ n, err := Parse(stored, testFuncs)48+ if err != nil {49+ t.Errorf("%s: %q: %v", c.tag, stored, err)50+ continue51+ }52+ // The printer's canonical text, localized, reads back the same.53+ printed := Localize(Text(n), l)54+ n2, err := Parse(Delocalize(printed, l), testFuncs)55+ if err != nil || Text(n2) != Text(n) {56+ t.Errorf("%s: printed %q reads back as %v %v", c.tag, printed, n2, err)57+ }58+ }59+}60+61+// TestLocalizePartial translates formulas still being typed.62+func TestLocalizePartial(t *testing.T) {63+ de := mustLocale(t, "de-DE")64+ for typed, want := range map[string]string{65+ "=SUM(1,5;": "=SUM(1.5,",66+ `=IF(A1;"x;`: `=IF(A1,"x;`,67+ "={1\\": "={1,",68+ "=ROUND(": "=ROUND(",69+ } {70+ if got := Delocalize(typed, de); got != want {71+ t.Errorf("%q = %q, want %q", typed, got, want)72+ }73+ }74+}