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>
| M | internal/ | 23 | ++++---- |
| M | internal/ | 54 | ------------------- |
| M | internal/ | 3 | +- |
| M | internal/ | 27 | --------- |
| R | internal/ | 48 | ++++++++-------- |
| A | internal/ | 87 | ++++++++++++++++++++++++++++++ |
@@ -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 {@@ -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")@@ -8,6 +8,7 @@ import (
88 tea "charm.land/bubbletea/v2"99 1010 "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/formula"1112 )1213 1314 // Undo and redo, fill, inserting and deleting rows and columns, and F4 in@@ -173,7 +174,7 @@ func (m *Model) entryError(err error, input string) {
173174 // toggleAbsolute cycles the reference at the caret through A1, $A$1, A$1174175 // and $A1, as F4 does in Sheets. A range cycles both corners.175176 func (m *Model) toggleAbsolute() {176- if buf, pos, ok := cycleRef(m.line.buf, m.line.pos); ok {177+ if buf, pos, ok := formula.CycleRef(m.line.buf, m.line.pos); ok {177178 m.line.buf, m.line.pos = buf, pos178179 }179180 }@@ -265,33 +265,6 @@ func TestF4CyclesReferences(t *testing.T) {
265265 }266266 }267267 268-func TestCycleRef(t *testing.T) {269- tests := []struct {270- in string271- caret int272- want string273- ok bool274- }{275- {"=a1", 3, "=$A$1", true},276- {"=A1+1", 1, "=$A$1+1", true},277- {"=SUM(A1:B2)", 10, "=SUM($A$1:$B$2)", true},278- {"=SUM(A1:B2)", 7, "=SUM($A$1:$B$2)", true},279- {"=SUM($A$1..B2)", 12, "=SUM(A$1..B$2)", true},280- {`="A1"`, 3, "", false},281- {"=LOG10(2)", 6, "", false},282- {"=1+2", 2, "", false},283- }284- for _, tt := range tests {285- out, pos, ok := cycleRef([]rune(tt.in), tt.caret)286- if ok != tt.ok || string(out) != tt.want {287- t.Errorf("cycleRef(%q, %d) = %q, %v; want %q", tt.in, tt.caret, string(out), ok, tt.want)288- }289- if ok && pos > len(out) {290- t.Errorf("caret %d past end of %q", pos, string(out))291- }292- }293-}294-295268 func TestInsertDeleteKeysFollowSelection(t *testing.T) {296269 m := newModel()297270 press(t, m, "1", "<tab>", "2", "<enter>", "=A1+B1", "<enter>", "<ctrl+home>")@@ -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:@@ -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+}