cat / 012
clone

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

main
012 / internal / ui / formula / formula.go
formula.go 8.5K
1// 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.5package formula67import (8 "slices"9 "strings"10 "unicode"1112 "src.rest/cat/012/internal/sheet"13)1415// 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.18func 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}3536// refScan finds cell references in a formula's text, outside strings.37type refScan struct {38 buf []rune39 quoted []bool // inside a string, quotes included40}4142func 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}5354func (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}5859// word returns the bounds of the run of reference characters around i.60func (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}7071// isRef reports whether buf[start:end] is a cell reference, not a name72// (@x) or a function (x().73func (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}8182// sep returns the length of a range separator at i, ":" or "..", or 0.83func (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}9293// corners returns the bounds of the reference at pos, widened to both94// corners of a range written A1:B2 or A1..B2.95func (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}116117// Caret describes the formula text before the caret: the word being118// typed, if it may be a function, name or sheet, and the innermost119// function call the caret is in. A sheet name being typed in quotes is a120// word starting with the quote, e.g. "'Q3 p". In a structured121// reference's brackets, Sales[Am, the word is the column or item being122// typed and Table the table's name.123type Caret struct {124 Word string125 WordStart int126 Fn string // e.g. "SUM", or "" outside any function's parentheses127 Arg int // index of the argument the caret is in128 Table string // the table whose brackets the caret is in, as written, or ""129}130131func isWordRune(r rune) bool {132 return r < unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)) || r == '_' || r == '.' || r == '$' || r == '@'133}134135// ScanCaret reads a formula up to pos the way the lexer does: strings are136// skipped, parentheses nest, and commas or semicolons at the innermost137// level separate arguments.138func ScanCaret(buf []rune, pos int) Caret {139 s := caretScan{start: -1, quote: -1}140 for i := 1; i < pos; i++ { // buf[0] is "=", "+" or "-"141 s.step(buf, i)142 }143 if s.inStr {144 return Caret{}145 }146 var c Caret147 if s.depth > 0 {148 c.Table, c.WordStart = s.table, s.item149 c.Word = string(buf[s.item:pos])150 s.quote = -1151 }152 if s.quote >= 0 {153 c.Word, c.WordStart = string(buf[s.quote:pos]), s.quote154 }155 for i := len(s.stack) - 1; i >= 0; i-- {156 if s.stack[i].fn != "" {157 c.Fn, c.Arg = s.stack[i].fn, s.stack[i].arg158 break159 }160 }161 if s.depth == 0 && s.quote < 0 && s.start >= 0 && (pos == len(buf) || !isWordRune(buf[pos])) {162 w := []rune(strings.TrimPrefix(string(buf[s.start:pos]), "@"))163 if len(w) > 0 && (unicode.IsLetter(w[0]) || w[0] == '_') {164 c.Word, c.WordStart = string(w), pos-len(w)165 }166 }167 return c168}169170// caretScan is ScanCaret's state as it reads.171type caretScan struct {172 stack []callFrame // the calls open at this point, innermost last173 inStr bool174 quote int // start of a quoted sheet name being read, or -1175 start int // start of the word being read, or -1176 prev string // the word just before a space or "(" (SUM ( is allowed)177 // In a structured reference's brackets: how deep, the table's name,178 // where the item being typed starts, and whether the next character179 // is escaped by '.180 depth int181 table string182 item int183 esc bool184}185186// callFrame is a function call the caret may be in.187type callFrame struct {188 fn string189 arg int190}191192// step reads buf[i].193func (s *caretScan) step(buf []rune, i int) {194 r := buf[i]195 if s.depth > 0 {196 s.bracket(r, i)197 return198 }199 if s.inStr {200 s.inStr = r != '"'201 return202 }203 if s.quote >= 0 {204 if r == '\'' {205 s.quote = -1206 }207 return208 }209 if isWordRune(r) {210 if s.start < 0 {211 s.start = i212 }213 return214 }215 if s.start >= 0 {216 s.prev, s.start = string(buf[s.start:i]), -1217 }218 switch r {219 case ' ':220 return // keep prev for "SUM ("221 case '"':222 s.inStr = true223 case '\'':224 s.quote = i225 case '[':226 s.table, s.depth, s.item = s.prev, 1, i+1227 case '(':228 s.stack = append(s.stack, callFrame{fn: strings.ToUpper(strings.TrimPrefix(s.prev, "@"))})229 case ')':230 if len(s.stack) > 0 {231 s.stack = s.stack[:len(s.stack)-1]232 }233 case ',', ';':234 if len(s.stack) > 0 {235 s.stack[len(s.stack)-1].arg++236 }237 }238 s.prev = ""239}240241// bracket reads r, at i, inside a structured reference's brackets:242// Sales[[#Headers],[Amount]]. Each [ or item separator starts an item;243// ' escapes the character after it.244func (s *caretScan) bracket(r rune, i int) {245 switch {246 case s.esc:247 s.esc = false248 case r == '\'':249 s.esc = true250 case r == '[':251 s.depth++252 s.item = i + 1253 case r == ']':254 if s.depth--; s.depth == 0 {255 s.table = ""256 }257 s.item = i + 1258 case (r == ',' || r == ';') && s.depth == 1:259 s.item = i + 1260 }261}262263// SplitArgs splits a signature's arguments at the top-level commas, so264// "[value2, ...]" stays one part.265func SplitArgs(args string) []string {266 var out []string267 depth, start := 0, 0268 for i, r := range args {269 switch r {270 case '[':271 depth++272 case ']':273 depth--274 case ',':275 if depth == 0 {276 out = append(out, strings.TrimSpace(args[start:i]))277 start = i + 1278 }279 }280 }281 if s := strings.TrimSpace(args[start:]); s != "" {282 out = append(out, s)283 }284 return out285}286287// ArgPart maps an argument index to the part of the signature that288// describes it: a repeating part like "[value2, ...]" covers every289// argument from its position on. It returns -1 past the last argument.290func ArgPart(parts []string, arg int, variadic bool) int {291 rep := slices.IndexFunc(parts, func(p string) bool { return strings.Contains(p, "...") })292 switch {293 case rep >= 0 && variadic && arg >= rep:294 return rep295 case arg < len(parts):296 return arg297 }298 return -1299}300301// nextMarkers returns the absolute markers that follow ref's in F4's302// cycle: A1 -> $A$1 -> A$1 -> $A1 -> A1.303func nextMarkers(ref string) markers {304 col := strings.HasPrefix(ref, "$")305 row := strings.Contains(ref[1:], "$")306 switch {307 case !col && !row:308 return markers{true, true}309 case col && row:310 return markers{false, true}311 case row:312 return markers{true, false}313 }314 return markers{}315}316317// markers says which parts of a reference are absolute.318type markers struct{ col, row bool }319320// withMarkers rewrites ref with the given absolute markers.321func withMarkers(ref string, marks markers) string {322 ref = strings.ToUpper(strings.ReplaceAll(ref, "$", ""))323 i := strings.IndexAny(ref, "0123456789")324 var b strings.Builder325 if marks.col {326 b.WriteByte('$')327 }328 b.WriteString(ref[:i])329 if marks.row {330 b.WriteByte('$')331 }332 b.WriteString(ref[i:])333 return b.String()334}