cycleRef (F4) and scanCaret (formula assistance) each did all their work in one function full of closures, with cognitive complexity 44 and 29. They move to formulatext.go, split into a refScan that finds references and range corners and a caretScan that reads one rune at a time.
- commit
- 3f83132cf8124f32b871a14953bee7b9cdc34499
- parent
- 0cbfb0aee8249aa425b68d9b64d98deab2b72376
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 117 | ------------- |
| M | internal/ | 113 | ------------ |
| A | internal/ | 278 | ++++++++++++++++++++++++++++++ |
@@ -4,7 +4,6 @@ import (
44 "slices"55 "strconv"66 "strings"7- "unicode"87 98 tea "charm.land/bubbletea/v2"109 "github.com/charmbracelet/x/ansi"@@ -41,84 +40,6 @@ type suggestion struct {
4140 fn bool // a function: accepting it adds "("4241 }4342 44-// caret describes the formula text before the caret: the word being45-// typed, if it may be a function or name, and the innermost function call46-// the caret is in.47-type caret struct {48- word string49- wordStart int50- fn string // e.g. "SUM", or "" outside any function's parentheses51- arg int // index of the argument the caret is in52-}53-54-func isWordRune(r rune) bool {55- return r < unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)) || r == '_' || r == '.' || r == '$' || r == '@'56-}57-58-// scanCaret reads a formula up to pos the way the lexer does: strings are59-// skipped, parentheses nest, and commas or semicolons at the innermost60-// level separate arguments.61-func scanCaret(buf []rune, pos int) caret {62- type frame struct {63- fn string64- arg int65- }66- var stack []frame67- var c caret68- inStr := false69- start := -1 // start of the word being read70- prev := "" // the word just before a space or "(" (SUM ( is allowed)71- for i := 1; i < pos; i++ { // buf[0] is "=", "+" or "-"72- r := buf[i]73- if inStr {74- inStr = r != '"'75- continue76- }77- if isWordRune(r) {78- if start < 0 {79- start = i80- }81- continue82- }83- if start >= 0 {84- prev, start = string(buf[start:i]), -185- }86- switch r {87- case ' ':88- continue // keep prev for "SUM ("89- case '"':90- inStr = true91- case '(':92- stack = append(stack, frame{fn: strings.ToUpper(strings.TrimPrefix(prev, "@"))})93- case ')':94- if len(stack) > 0 {95- stack = stack[:len(stack)-1]96- }97- case ',', ';':98- if len(stack) > 0 {99- stack[len(stack)-1].arg++100- }101- }102- prev = ""103- }104- if inStr {105- return caret{}106- }107- for i := len(stack) - 1; i >= 0; i-- {108- if stack[i].fn != "" {109- c.fn, c.arg = stack[i].fn, stack[i].arg110- break111- }112- }113- if start >= 0 && (pos == len(buf) || !isWordRune(buf[pos])) {114- w := []rune(strings.TrimPrefix(string(buf[start:pos]), "@"))115- if len(w) > 0 && (unicode.IsLetter(w[0]) || w[0] == '_') {116- c.word, c.wordStart = string(w), pos-len(w)117- }118- }119- return c120-}121-12243 // suggestions lists the functions and named ranges for word: those12344 // starting with it first (names before functions), then, from two12445 // letters on, those containing it, such as COUNTIFS for "ifs". A word@@ -350,41 +271,3 @@ func (m *Model) signature(buf []rune, pos int) (sig, desc string) {
350271 b.WriteString(")")351272 return b.String(), f.Desc352273 }353-354-// splitArgs splits a signature's arguments at the top-level commas, so355-// "[value2, ...]" stays one part.356-func splitArgs(args string) []string {357- var out []string358- depth, start := 0, 0359- for i, r := range args {360- switch r {361- case '[':362- depth++363- case ']':364- depth--365- case ',':366- if depth == 0 {367- out = append(out, strings.TrimSpace(args[start:i]))368- start = i + 1369- }370- }371- }372- if s := strings.TrimSpace(args[start:]); s != "" {373- out = append(out, s)374- }375- return out376-}377-378-// argPart maps an argument index to the part of the signature that379-// describes it: a repeating part like "[value2, ...]" covers every380-// argument from its position on. It returns -1 past the last argument.381-func argPart(parts []string, arg int, variadic bool) int {382- rep := slices.IndexFunc(parts, func(p string) bool { return strings.Contains(p, "...") })383- switch {384- case rep >= 0 && variadic && arg >= rep:385- return rep386- case arg < len(parts):387- return arg388- }389- return -1390-}@@ -3,8 +3,6 @@ package ui
33 import (44 "errors"55 "fmt"6- "slices"7- "strings"86 "unicode/utf8"97 108 tea "charm.land/bubbletea/v2"@@ -180,117 +178,6 @@ func (m *Model) toggleAbsolute() {
180178 }181179 }182180 183-// cycleRef finds the reference (or range) at or just before pos in a184-// formula and returns the text with its absolute markers cycled, and the185-// caret moved to the reference's end.186-func cycleRef(buf []rune, pos int) ([]rune, int, bool) {187- quoted := make([]bool, len(buf))188- in := false189- for i, r := range buf {190- if r == '"' {191- in = !in192- }193- quoted[i] = in || r == '"'194- }195- isRefRune := func(i int) bool {196- r := buf[i]197- return !quoted[i] && (r == '$' || r >= '0' && r <= '9' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z')198- }199- // word returns the bounds of the run of reference characters around i.200- word := func(i int) (int, int) {201- start, end := i, i202- for start > 0 && isRefRune(start-1) {203- start--204- }205- for end < len(buf) && isRefRune(end) {206- end++207- }208- return start, end209- }210- isRef := func(start, end int) bool {211- if start == end || start > 0 && buf[start-1] == '@' || end < len(buf) && buf[end] == '(' {212- return false213- }214- _, ok := sheet.ParseAddr(string(buf[start:end]))215- return ok216- }217- start, end := word(pos)218- if !isRef(start, end) {219- return nil, 0, false220- }221- // Widen to a range written A1:B2 or A1..B2.222- sep := func(i int) int {223- switch {224- case i < len(buf) && buf[i] == ':':225- return 1226- case i+1 < len(buf) && buf[i] == '.' && buf[i+1] == '.':227- return 2228- }229- return 0230- }231- corners := [][2]int{{start, end}}232- if n := sep(end); n > 0 {233- if s2, e2 := word(end + n); s2 == end+n && isRef(s2, e2) {234- corners = append(corners, [2]int{s2, e2})235- }236- } else {237- for n := 1; n <= 2 && start-n > 0; n++ {238- if sep(start-n) == n {239- if s0, e0 := word(start - n - 1); e0 == start-n && isRef(s0, e0) {240- corners = [][2]int{{s0, e0}, {start, end}}241- }242- break243- }244- }245- }246- next := nextMarkers(string(buf[corners[0][0]:corners[0][1]]))247- out := slices.Clone(buf[:corners[0][0]])248- for i, c := range corners {249- if i > 0 {250- out = append(out, buf[corners[i-1][1]:c[0]]...)251- }252- out = append(out, []rune(withMarkers(string(buf[c[0]:c[1]]), next))...)253- }254- caret := len(out)255- out = append(out, buf[corners[len(corners)-1][1]:]...)256- return out, caret, true257-}258-259-// nextMarkers returns the absolute markers that follow ref's in F4's260-// cycle: A1 -> $A$1 -> A$1 -> $A1 -> A1.261-func nextMarkers(ref string) markers {262- col := strings.HasPrefix(ref, "$")263- row := strings.Contains(ref[1:], "$")264- switch {265- case !col && !row:266- return markers{true, true}267- case col && row:268- return markers{false, true}269- case row:270- return markers{true, false}271- }272- return markers{}273-}274-275-// markers says which parts of a reference are absolute.276-type markers struct{ col, row bool }277-278-// withMarkers rewrites ref with the given absolute markers.279-func withMarkers(ref string, marks markers) string {280- ref = strings.ToUpper(strings.ReplaceAll(ref, "$", ""))281- i := strings.IndexAny(ref, "0123456789")282- var b strings.Builder283- if marks.col {284- b.WriteByte('$')285- }286- b.WriteString(ref[:i])287- if marks.row {288- b.WriteByte('$')289- }290- b.WriteString(ref[i:])291- return b.String()292-}293-294181 // countCells describes a paste for the context line.295182 func countCells(r sheet.Rect) string {296183 n := (r.To.Col - r.From.Col + 1) * (r.To.Row - r.From.Row + 1)@@ -0,0 +1,278 @@
1+package ui2+3+import (4+ "slices"5+ "strings"6+ "unicode"7+8+ "github.com/FelineStateMachine/012/internal/sheet"9+)10+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 a16+// formula and returns the text with its absolute markers cycled, and the17+// caret moved to the reference's end.18+func cycleRef(buf []rune, pos int) ([]rune, int, bool) {19+ corners, ok := newRefScan(buf).corners(pos)20+ if !ok {21+ return nil, 0, false22+ }23+ next := nextMarkers(string(buf[corners[0][0]:corners[0][1]]))24+ out := slices.Clone(buf[:corners[0][0]])25+ for i, c := range corners {26+ if i > 0 {27+ out = append(out, buf[corners[i-1][1]:c[0]]...)28+ }29+ out = append(out, []rune(withMarkers(string(buf[c[0]:c[1]]), next))...)30+ }31+ caret := len(out)32+ out = append(out, buf[corners[len(corners)-1][1]:]...)33+ return out, caret, true34+}35+36+// refScan finds cell references in a formula's text, outside strings.37+type refScan struct {38+ buf []rune39+ quoted []bool // inside a string, quotes included40+}41+42+func newRefScan(buf []rune) refScan {43+ quoted := make([]bool, len(buf))44+ in := false45+ for i, r := range buf {46+ if r == '"' {47+ in = !in48+ }49+ quoted[i] = in || r == '"'50+ }51+ return refScan{buf: buf, quoted: quoted}52+}53+54+func (s refScan) isRefRune(i int) bool {55+ r := s.buf[i]56+ return !s.quoted[i] && (r == '$' || r >= '0' && r <= '9' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z')57+}58+59+// word returns the bounds of the run of reference characters around i.60+func (s refScan) word(i int) (int, int) {61+ start, end := i, i62+ for start > 0 && s.isRefRune(start-1) {63+ start--64+ }65+ for end < len(s.buf) && s.isRefRune(end) {66+ end++67+ }68+ return start, end69+}70+71+// isRef reports whether buf[start:end] is a cell reference, not a name72+// (@x) or a function (x().73+func (s refScan) isRef(start, end int) bool {74+ buf := s.buf75+ if start == end || start > 0 && buf[start-1] == '@' || end < len(buf) && buf[end] == '(' {76+ return false77+ }78+ _, ok := sheet.ParseAddr(string(buf[start:end]))79+ return ok80+}81+82+// sep returns the length of a range separator at i, ":" or "..", or 0.83+func (s refScan) sep(i int) int {84+ switch {85+ case i < len(s.buf) && s.buf[i] == ':':86+ return 187+ case i+1 < len(s.buf) && s.buf[i] == '.' && s.buf[i+1] == '.':88+ return 289+ }90+ return 091+}92+93+// corners returns the bounds of the reference at pos, widened to both94+// corners of a range written A1:B2 or A1..B2.95+func (s refScan) corners(pos int) ([][2]int, bool) {96+ start, end := s.word(pos)97+ if !s.isRef(start, end) {98+ return nil, false99+ }100+ if n := s.sep(end); n > 0 {101+ if s2, e2 := s.word(end + n); s2 == end+n && s.isRef(s2, e2) {102+ return [][2]int{{start, end}, {s2, e2}}, true103+ }104+ return [][2]int{{start, end}}, true105+ }106+ for n := 1; n <= 2 && start-n > 0; n++ {107+ if s.sep(start-n) == n {108+ if s0, e0 := s.word(start - n - 1); e0 == start-n && s.isRef(s0, e0) {109+ return [][2]int{{s0, e0}, {start, end}}, true110+ }111+ break112+ }113+ }114+ return [][2]int{{start, end}}, true115+}116+117+// caret describes the formula text before the caret: the word being118+// typed, if it may be a function or name, and the innermost function call119+// 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 in125+}126+127+func isWordRune(r rune) bool {128+ return r < unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)) || r == '_' || r == '.' || r == '$' || r == '@'129+}130+131+// scanCaret reads a formula up to pos the way the lexer does: strings are132+// skipped, parentheses nest, and commas or semicolons at the innermost133+// level separate arguments.134+func scanCaret(buf []rune, pos int) caret {135+ s := caretScan{start: -1}136+ for i := 1; i < pos; i++ { // buf[0] is "=", "+" or "-"137+ s.step(buf, i)138+ }139+ if s.inStr {140+ return caret{}141+ }142+ var c caret143+ for i := len(s.stack) - 1; i >= 0; i-- {144+ if s.stack[i].fn != "" {145+ c.fn, c.arg = s.stack[i].fn, s.stack[i].arg146+ break147+ }148+ }149+ if s.start >= 0 && (pos == len(buf) || !isWordRune(buf[pos])) {150+ w := []rune(strings.TrimPrefix(string(buf[s.start:pos]), "@"))151+ if len(w) > 0 && (unicode.IsLetter(w[0]) || w[0] == '_') {152+ c.word, c.wordStart = string(w), pos-len(w)153+ }154+ }155+ return c156+}157+158+// caretScan is scanCaret's state as it reads.159+type caretScan struct {160+ stack []callFrame // the calls open at this point, innermost last161+ inStr bool162+ start int // start of the word being read, or -1163+ prev string // the word just before a space or "(" (SUM ( is allowed)164+}165+166+// callFrame is a function call the caret may be in.167+type callFrame struct {168+ fn string169+ arg int170+}171+172+// step reads buf[i].173+func (s *caretScan) step(buf []rune, i int) {174+ r := buf[i]175+ if s.inStr {176+ s.inStr = r != '"'177+ return178+ }179+ if isWordRune(r) {180+ if s.start < 0 {181+ s.start = i182+ }183+ return184+ }185+ if s.start >= 0 {186+ s.prev, s.start = string(buf[s.start:i]), -1187+ }188+ switch r {189+ case ' ':190+ return // keep prev for "SUM ("191+ case '"':192+ s.inStr = true193+ case '(':194+ s.stack = append(s.stack, callFrame{fn: strings.ToUpper(strings.TrimPrefix(s.prev, "@"))})195+ case ')':196+ if len(s.stack) > 0 {197+ s.stack = s.stack[:len(s.stack)-1]198+ }199+ case ',', ';':200+ if len(s.stack) > 0 {201+ s.stack[len(s.stack)-1].arg++202+ }203+ }204+ s.prev = ""205+}206+207+// splitArgs splits a signature's arguments at the top-level commas, so208+// "[value2, ...]" stays one part.209+func splitArgs(args string) []string {210+ var out []string211+ depth, start := 0, 0212+ for i, r := range args {213+ switch r {214+ case '[':215+ depth++216+ case ']':217+ depth--218+ case ',':219+ if depth == 0 {220+ out = append(out, strings.TrimSpace(args[start:i]))221+ start = i + 1222+ }223+ }224+ }225+ if s := strings.TrimSpace(args[start:]); s != "" {226+ out = append(out, s)227+ }228+ return out229+}230+231+// argPart maps an argument index to the part of the signature that232+// describes it: a repeating part like "[value2, ...]" covers every233+// argument from its position on. It returns -1 past the last argument.234+func argPart(parts []string, arg int, variadic bool) int {235+ rep := slices.IndexFunc(parts, func(p string) bool { return strings.Contains(p, "...") })236+ switch {237+ case rep >= 0 && variadic && arg >= rep:238+ return rep239+ case arg < len(parts):240+ return arg241+ }242+ return -1243+}244+245+// nextMarkers returns the absolute markers that follow ref's in F4's246+// cycle: A1 -> $A$1 -> A$1 -> $A1 -> A1.247+func nextMarkers(ref string) markers {248+ col := strings.HasPrefix(ref, "$")249+ row := strings.Contains(ref[1:], "$")250+ switch {251+ case !col && !row:252+ return markers{true, true}253+ case col && row:254+ return markers{false, true}255+ case row:256+ return markers{true, false}257+ }258+ return markers{}259+}260+261+// markers says which parts of a reference are absolute.262+type markers struct{ col, row bool }263+264+// withMarkers rewrites ref with the given absolute markers.265+func withMarkers(ref string, marks markers) string {266+ ref = strings.ToUpper(strings.ReplaceAll(ref, "$", ""))267+ i := strings.IndexAny(ref, "0123456789")268+ var b strings.Builder269+ if marks.col {270+ b.WriteByte('$')271+ }272+ b.WriteString(ref[:i])273+ if marks.row {274+ b.WriteByte('$')275+ }276+ b.WriteString(ref[i:])277+ return b.String()278+}