cat / 012
clone

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

Read formula text with small scanner types

Dami committed 3f83132
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>
3 files changed, +278 -230
Minternal/ui/assist.go117-------------
Minternal/ui/edit.go113------------
Ainternal/ui/formulatext.go278++++++++++++++++++++++++++++++
M internal/ui/assist.go +0 -117
@@ -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-}
M internal/ui/edit.go +0 -113
@@ -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)
A internal/ui/formulatext.go +278 -0
@@ -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+}