cat / 012
clone

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

Move formula text scanning into internal/ui/formula

Dami committed c505812
Finding the reference F4 cycles and reading the word and call around
the caret depend only on the entry's runes and the function table, so
they form a leaf package with their tests, and package ui imports it.
commit
c505812f20768b60cdeb4ea48da5236d82218aee
parent
a248713f24d98b026276927d4c2c71f03e6b32dc
author
Dami <dami.etoile@gmail.com>
6 files changed, +125 -117
Minternal/ui/assist.go23++++----
Minternal/ui/assist_test.go54-------------------
Minternal/ui/edit.go3+-
Minternal/ui/edit_test.go27---------
Rinternal/ui/formulatext.go → internal/ui/formula/formula.go48++++++++--------
Ainternal/ui/formula/formula_test.go87++++++++++++++++++++++++++++++
M internal/ui/assist.go +12 -11
@@ -9,6 +9,7 @@ import (
99 "github.com/charmbracelet/x/ansi"1010 1111 "github.com/FelineStateMachine/012/internal/sheet"12+ "github.com/FelineStateMachine/012/internal/ui/formula"1213 "github.com/FelineStateMachine/012/internal/ui/theme"1314 )1415
@@ -78,16 +79,16 @@ func (a *assist) shown(m *Model) ([]suggestion, int) {
7879 if (m.mode != modeEnter && m.mode != modeEdit) || !m.line.isFormula() || !a.active || m.overlay != nil {7980 return nil, 08081 }81- c := scanCaret(m.line.buf, m.line.pos)82- if c.word == "" {82+ c := formula.ScanCaret(m.line.buf, m.line.pos)83+ if c.Word == "" {8384 return nil, 08485 }85- list := suggestions(m.sheet, c.word)86- if len(list) == 0 || len(list) == 1 && !list[0].fn && strings.EqualFold(list[0].name, c.word) {86+ list := suggestions(m.sheet, c.Word)87+ if len(list) == 0 || len(list) == 1 && !list[0].fn && strings.EqualFold(list[0].name, c.Word) {8788 return nil, 08889 }8990 a.sel = min(a.sel, len(list)-1)90- return list, c.wordStart91+ return list, c.WordStart9192 }9293 9394 // typeKey applies a line-editing key to the entry. Typing or deleting
@@ -225,7 +226,7 @@ func (a *assist) status(m *Model) (desc, keys string, ok bool) {
225226 // inFunction reports whether the caret is inside a known function's226227 // parentheses.227228 func (l *lineEdit) inFunction() bool {228- _, ok := sheet.LookupFunc(scanCaret(l.buf, l.pos).fn)229+ _, ok := sheet.LookupFunc(formula.ScanCaret(l.buf, l.pos).Fn)229230 return ok230231 }231232
@@ -250,13 +251,13 @@ func signatureLine(th *theme.Theme, width int, buf []rune, pos int, hints string
250251 // SUM(value1, [value2, ...]), with the current argument marked, and what251252 // the function does. It is "" outside a function.252253 func signature(th *theme.Theme, buf []rune, pos int) (sig, desc string) {253- c := scanCaret(buf, pos)254- f, ok := sheet.LookupFunc(c.fn)255- if c.fn == "" || !ok {254+ c := formula.ScanCaret(buf, pos)255+ f, ok := sheet.LookupFunc(c.Fn)256+ if c.Fn == "" || !ok {256257 return "", ""257258 }258- parts := splitArgs(f.Args)259- cur := argPart(parts, c.arg, f.Max < 0)259+ parts := formula.SplitArgs(f.Args)260+ cur := formula.ArgPart(parts, c.Arg, f.Max < 0)260261 var b strings.Builder261262 b.WriteString(th.Key.Render(f.Name) + "(")262263 for i, p := range parts {
M internal/ui/assist_test.go +0 -54
@@ -7,60 +7,6 @@ import (
77 tea "charm.land/bubbletea/v2"88 )99 10-func TestScanCaret(t *testing.T) {11- for _, tc := range []struct {12- text string // | marks the caret13- word string14- fn string15- arg int16- }{17- {"=SU|", "SU", "", 0},18- {"=@su|", "su", "", 0},19- {"=SUM(|", "", "SUM", 0},20- {"=SUM(A1, B|", "B", "SUM", 1},21- {"=IF(A1>2, SUM(B1; B2), |", "", "IF", 2},22- {"=IF(A1, (1+|", "", "IF", 1},23- {`=IF(A1="a,b(", |`, "", "IF", 1},24- {`=CONCAT("SU|`, "", "", 0},25- {"=SUM (1, |", "", "SUM", 1},26- {"=jev.te|", "jev.te", "", 0},27- {"=A1+1|", "", "", 0},28- {"=SU|M(1)", "", "", 0},29- {"=Sales|", "Sales", "", 0},30- } {31- i := strings.Index(tc.text, "|")32- buf := []rune(strings.Replace(tc.text, "|", "", 1))33- c := scanCaret(buf, len([]rune(tc.text[:i])))34- if c.word != tc.word || c.fn != tc.fn || c.arg != tc.arg {35- t.Errorf("%s: word %q fn %q arg %d", tc.text, c.word, c.fn, c.arg)36- }37- }38-}39-40-func TestArgPart(t *testing.T) {41- for _, tc := range []struct {42- args string43- arg int44- variadic bool45- want string46- }{47- {"value1, [value2, ...]", 0, true, "value1"},48- {"value1, [value2, ...]", 4, true, "[value2, ...]"},49- {"condition, value_if_true, [value_if_false]", 2, false, "[value_if_false]"},50- {"condition, value_if_true, [value_if_false]", 3, false, ""},51- {"sum_range, criteria_range1, criterion1, [criteria_range2, criterion2, ...]", 5, true, "[criteria_range2, criterion2, ...]"},52- } {53- parts := splitArgs(tc.args)54- got := ""55- if i := argPart(parts, tc.arg, tc.variadic); i >= 0 {56- got = parts[i]57- }58- if got != tc.want {59- t.Errorf("%s arg %d: %q, want %q", tc.args, tc.arg, got, tc.want)60- }61- }62-}63-6410 func TestAutocompleteFunctions(t *testing.T) {6511 m := newModel()6612 press(t, m, "=su")
M internal/ui/edit.go +2 -1
M internal/ui/edit_test.go +0 -27
R internal/ui/formulatext.go → internal/ui/formula/formula.go +24 -24
@@ -1,4 +1,8 @@
1-package ui1+// Package formula reads the formula being typed in the UI: the2+// reference F4 cycles through absolute markers, and the word and function3+// call around the caret that formula assistance follows. It works on the4+// entry's runes alone.5+package formula26 37 import (48 "slices"
@@ -8,14 +12,10 @@ import (
812 "github.com/FelineStateMachine/012/internal/sheet"913 )1014 11-// Reading the formula being typed: the reference F4 cycles, and the word12-// and function call around the caret that formula assistance follows.13-// These work on the entry's runes alone.14-15-// cycleRef finds the reference (or range) at or just before pos in a15+// CycleRef finds the reference (or range) at or just before pos in a1616 // formula and returns the text with its absolute markers cycled, and the1717 // caret moved to the reference's end.18-func cycleRef(buf []rune, pos int) ([]rune, int, bool) {18+func CycleRef(buf []rune, pos int) ([]rune, int, bool) {1919 corners, ok := newRefScan(buf).corners(pos)2020 if !ok {2121 return nil, 0, false
@@ -114,48 +114,48 @@ func (s refScan) corners(pos int) ([][2]int, bool) {
114114 return [][2]int{{start, end}}, true115115 }116116 117-// caret describes the formula text before the caret: the word being117+// Caret describes the formula text before the caret: the word being118118 // typed, if it may be a function or name, and the innermost function call119119 // the caret is in.120-type caret struct {121- word string122- wordStart int123- fn string // e.g. "SUM", or "" outside any function's parentheses124- arg int // index of the argument the caret is in120+type Caret struct {121+ Word string122+ WordStart int123+ Fn string // e.g. "SUM", or "" outside any function's parentheses124+ Arg int // index of the argument the caret is in125125 }126126 127127 func isWordRune(r rune) bool {128128 return r < unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)) || r == '_' || r == '.' || r == '$' || r == '@'129129 }130130 131-// scanCaret reads a formula up to pos the way the lexer does: strings are131+// ScanCaret reads a formula up to pos the way the lexer does: strings are132132 // skipped, parentheses nest, and commas or semicolons at the innermost133133 // level separate arguments.134-func scanCaret(buf []rune, pos int) caret {134+func ScanCaret(buf []rune, pos int) Caret {135135 s := caretScan{start: -1}136136 for i := 1; i < pos; i++ { // buf[0] is "=", "+" or "-"137137 s.step(buf, i)138138 }139139 if s.inStr {140- return caret{}140+ return Caret{}141141 }142- var c caret142+ var c Caret143143 for i := len(s.stack) - 1; i >= 0; i-- {144144 if s.stack[i].fn != "" {145- c.fn, c.arg = s.stack[i].fn, s.stack[i].arg145+ c.Fn, c.Arg = s.stack[i].fn, s.stack[i].arg146146 break147147 }148148 }149149 if s.start >= 0 && (pos == len(buf) || !isWordRune(buf[pos])) {150150 w := []rune(strings.TrimPrefix(string(buf[s.start:pos]), "@"))151151 if len(w) > 0 && (unicode.IsLetter(w[0]) || w[0] == '_') {152- c.word, c.wordStart = string(w), pos-len(w)152+ c.Word, c.WordStart = string(w), pos-len(w)153153 }154154 }155155 return c156156 }157157 158-// caretScan is scanCaret's state as it reads.158+// caretScan is ScanCaret's state as it reads.159159 type caretScan struct {160160 stack []callFrame // the calls open at this point, innermost last161161 inStr bool
@@ -204,9 +204,9 @@ func (s *caretScan) step(buf []rune, i int) {
204204 s.prev = ""205205 }206206 207-// splitArgs splits a signature's arguments at the top-level commas, so207+// SplitArgs splits a signature's arguments at the top-level commas, so208208 // "[value2, ...]" stays one part.209-func splitArgs(args string) []string {209+func SplitArgs(args string) []string {210210 var out []string211211 depth, start := 0, 0212212 for i, r := range args {
@@ -228,10 +228,10 @@ func splitArgs(args string) []string {
228228 return out229229 }230230 231-// argPart maps an argument index to the part of the signature that231+// ArgPart maps an argument index to the part of the signature that232232 // describes it: a repeating part like "[value2, ...]" covers every233233 // argument from its position on. It returns -1 past the last argument.234-func argPart(parts []string, arg int, variadic bool) int {234+func ArgPart(parts []string, arg int, variadic bool) int {235235 rep := slices.IndexFunc(parts, func(p string) bool { return strings.Contains(p, "...") })236236 switch {237237 case rep >= 0 && variadic && arg >= rep:
A internal/ui/formula/formula_test.go +87 -0
@@ -0,0 +1,87 @@
1+package formula2+3+import (4+ "strings"5+ "testing"6+)7+8+func TestScanCaret(t *testing.T) {9+ for _, tc := range []struct {10+ text string // | marks the caret11+ word string12+ fn string13+ arg int14+ }{15+ {"=SU|", "SU", "", 0},16+ {"=@su|", "su", "", 0},17+ {"=SUM(|", "", "SUM", 0},18+ {"=SUM(A1, B|", "B", "SUM", 1},19+ {"=IF(A1>2, SUM(B1; B2), |", "", "IF", 2},20+ {"=IF(A1, (1+|", "", "IF", 1},21+ {`=IF(A1="a,b(", |`, "", "IF", 1},22+ {`=CONCAT("SU|`, "", "", 0},23+ {"=SUM (1, |", "", "SUM", 1},24+ {"=jev.te|", "jev.te", "", 0},25+ {"=A1+1|", "", "", 0},26+ {"=SU|M(1)", "", "", 0},27+ {"=Sales|", "Sales", "", 0},28+ } {29+ i := strings.Index(tc.text, "|")30+ buf := []rune(strings.Replace(tc.text, "|", "", 1))31+ c := ScanCaret(buf, len([]rune(tc.text[:i])))32+ if c.Word != tc.word || c.Fn != tc.fn || c.Arg != tc.arg {33+ t.Errorf("%s: word %q fn %q arg %d", tc.text, c.Word, c.Fn, c.Arg)34+ }35+ }36+}37+38+func TestArgPart(t *testing.T) {39+ for _, tc := range []struct {40+ args string41+ arg int42+ variadic bool43+ want string44+ }{45+ {"value1, [value2, ...]", 0, true, "value1"},46+ {"value1, [value2, ...]", 4, true, "[value2, ...]"},47+ {"condition, value_if_true, [value_if_false]", 2, false, "[value_if_false]"},48+ {"condition, value_if_true, [value_if_false]", 3, false, ""},49+ {"sum_range, criteria_range1, criterion1, [criteria_range2, criterion2, ...]", 5, true, "[criteria_range2, criterion2, ...]"},50+ } {51+ parts := SplitArgs(tc.args)52+ got := ""53+ if i := ArgPart(parts, tc.arg, tc.variadic); i >= 0 {54+ got = parts[i]55+ }56+ if got != tc.want {57+ t.Errorf("%s arg %d: %q, want %q", tc.args, tc.arg, got, tc.want)58+ }59+ }60+}61+62+func TestCycleRef(t *testing.T) {63+ tests := []struct {64+ in string65+ caret int66+ want string67+ ok bool68+ }{69+ {"=a1", 3, "=$A$1", true},70+ {"=A1+1", 1, "=$A$1+1", true},71+ {"=SUM(A1:B2)", 10, "=SUM($A$1:$B$2)", true},72+ {"=SUM(A1:B2)", 7, "=SUM($A$1:$B$2)", true},73+ {"=SUM($A$1..B2)", 12, "=SUM(A$1..B$2)", true},74+ {`="A1"`, 3, "", false},75+ {"=LOG10(2)", 6, "", false},76+ {"=1+2", 2, "", false},77+ }78+ for _, tt := range tests {79+ out, pos, ok := CycleRef([]rune(tt.in), tt.caret)80+ if ok != tt.ok || string(out) != tt.want {81+ t.Errorf("CycleRef(%q, %d) = %q, %v; want %q", tt.in, tt.caret, string(out), ok, tt.want)82+ }83+ if ok && pos > len(out) {84+ t.Errorf("caret %d past end of %q", pos, string(out))85+ }86+ }87+}