| 1 | package calc |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "regexp" |
| 6 | "strconv" |
| 7 | "strings" |
| 8 | ) |
| 9 | |
| 10 | // tokKind classifies a lexed token. Formula text itself never appears in |
| 11 | // error messages built around these tokens — only positions and reasons — |
| 12 | // because a formula can carry sensitive cell content. |
| 13 | type tokKind int |
| 14 | |
| 15 | const ( |
| 16 | tEOF tokKind = iota |
| 17 | tNum |
| 18 | tStr |
| 19 | tIdent |
| 20 | tRef |
| 21 | tOp |
| 22 | tLParen |
| 23 | tRParen |
| 24 | tComma |
| 25 | tColon |
| 26 | ) |
| 27 | |
| 28 | type token struct { |
| 29 | kind tokKind |
| 30 | pos int // byte offset in the source, for error reporting only |
| 31 | |
| 32 | num float64 |
| 33 | str string |
| 34 | ident string |
| 35 | op string |
| 36 | ref refToken |
| 37 | } |
| 38 | |
| 39 | // refToken is a lexed single-cell reference, decoded but not yet an AST |
| 40 | // node (the parser wraps it in *Ref or joins two into *Range). |
| 41 | type refToken struct { |
| 42 | colPos, rowPos int |
| 43 | absCol, absRow bool |
| 44 | } |
| 45 | |
| 46 | var refPattern = regexp.MustCompile(`^\$?[A-Za-z]{1,3}\$?[0-9]+$`) |
| 47 | |
| 48 | // lex scans src into a token stream terminated by a single tEOF token. |
| 49 | func lex(src string) ([]token, error) { |
| 50 | var toks []token |
| 51 | i := 0 |
| 52 | n := len(src) |
| 53 | for i < n { |
| 54 | c := src[i] |
| 55 | switch { |
| 56 | case c == ' ' || c == '\t' || c == '\n' || c == '\r': |
| 57 | i++ |
| 58 | case isDigit(c) || (c == '.' && i+1 < n && isDigit(src[i+1])): |
| 59 | tok, next, err := lexNumber(src, i) |
| 60 | if err != nil { |
| 61 | return nil, err |
| 62 | } |
| 63 | toks = append(toks, tok) |
| 64 | i = next |
| 65 | case c == '"': |
| 66 | tok, next, err := lexString(src, i) |
| 67 | if err != nil { |
| 68 | return nil, err |
| 69 | } |
| 70 | toks = append(toks, tok) |
| 71 | i = next |
| 72 | case isLetter(c) || c == '$': |
| 73 | tok, next, err := lexWord(src, i) |
| 74 | if err != nil { |
| 75 | return nil, err |
| 76 | } |
| 77 | toks = append(toks, tok) |
| 78 | i = next |
| 79 | default: |
| 80 | tok, next, err := lexOperator(src, i) |
| 81 | if err != nil { |
| 82 | return nil, err |
| 83 | } |
| 84 | toks = append(toks, tok) |
| 85 | i = next |
| 86 | } |
| 87 | } |
| 88 | toks = append(toks, token{kind: tEOF, pos: n}) |
| 89 | return toks, nil |
| 90 | } |
| 91 | |
| 92 | func isDigit(c byte) bool { return c >= '0' && c <= '9' } |
| 93 | |
| 94 | func isLetter(c byte) bool { |
| 95 | return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') |
| 96 | } |
| 97 | |
| 98 | func isWordChar(c byte) bool { |
| 99 | return isLetter(c) || isDigit(c) || c == '$' |
| 100 | } |
| 101 | |
| 102 | func lexNumber(src string, start int) (token, int, error) { |
| 103 | i := start |
| 104 | n := len(src) |
| 105 | for i < n && isDigit(src[i]) { |
| 106 | i++ |
| 107 | } |
| 108 | if i < n && src[i] == '.' { |
| 109 | i++ |
| 110 | for i < n && isDigit(src[i]) { |
| 111 | i++ |
| 112 | } |
| 113 | } |
| 114 | if i < n && (src[i] == 'e' || src[i] == 'E') { |
| 115 | j := i + 1 |
| 116 | if j < n && (src[j] == '+' || src[j] == '-') { |
| 117 | j++ |
| 118 | } |
| 119 | if j < n && isDigit(src[j]) { |
| 120 | i = j |
| 121 | for i < n && isDigit(src[i]) { |
| 122 | i++ |
| 123 | } |
| 124 | } |
| 125 | } |
| 126 | v, err := strconv.ParseFloat(src[start:i], 64) |
| 127 | if err != nil { |
| 128 | return token{}, 0, fmt.Errorf("calc: malformed number literal at position %d", start) |
| 129 | } |
| 130 | return token{kind: tNum, pos: start, num: v}, i, nil |
| 131 | } |
| 132 | |
| 133 | func lexString(src string, start int) (token, int, error) { |
| 134 | i := start + 1 |
| 135 | n := len(src) |
| 136 | var sb strings.Builder |
| 137 | for { |
| 138 | if i >= n { |
| 139 | return token{}, 0, fmt.Errorf("calc: unterminated string literal starting at position %d", start) |
| 140 | } |
| 141 | if src[i] == '"' { |
| 142 | if i+1 < n && src[i+1] == '"' { |
| 143 | sb.WriteByte('"') |
| 144 | i += 2 |
| 145 | continue |
| 146 | } |
| 147 | i++ |
| 148 | break |
| 149 | } |
| 150 | sb.WriteByte(src[i]) |
| 151 | i++ |
| 152 | } |
| 153 | return token{kind: tStr, pos: start, str: sb.String()}, i, nil |
| 154 | } |
| 155 | |
| 156 | // lexWord scans a maximal run of letters/digits/'$' and decides whether it |
| 157 | // is a reference or an identifier (a bare word, a boolean literal, or a |
| 158 | // function name). A word can match the reference shape and still be a |
| 159 | // function name — e.g. LOG10 is three letters followed by digits, exactly |
| 160 | // refPattern's shape — so before committing to tRef we peek past any |
| 161 | // whitespace for an opening '(': a word immediately followed by a call |
| 162 | // parenthesis is always a function name, never a reference. |
| 163 | func lexWord(src string, start int) (token, int, error) { |
| 164 | i := start |
| 165 | n := len(src) |
| 166 | for i < n && isWordChar(src[i]) { |
| 167 | i++ |
| 168 | } |
| 169 | w := src[start:i] |
| 170 | |
| 171 | if refPattern.MatchString(w) && !followedByCallParen(src, i) { |
| 172 | ref, err := decodeRef(w) |
| 173 | if err != nil { |
| 174 | return token{}, 0, fmt.Errorf("calc: malformed reference at position %d", start) |
| 175 | } |
| 176 | return token{kind: tRef, pos: start, ref: ref}, i, nil |
| 177 | } |
| 178 | if strings.Contains(w, "$") { |
| 179 | return token{}, 0, fmt.Errorf("calc: malformed reference at position %d", start) |
| 180 | } |
| 181 | return token{kind: tIdent, pos: start, ident: w}, i, nil |
| 182 | } |
| 183 | |
| 184 | // followedByCallParen reports whether the next non-whitespace character |
| 185 | // starting at i is '(', which disambiguates a word like LOG10 as a |
| 186 | // function name rather than a reference. |
| 187 | func followedByCallParen(src string, i int) bool { |
| 188 | for i < len(src) && (src[i] == ' ' || src[i] == '\t' || src[i] == '\n' || src[i] == '\r') { |
| 189 | i++ |
| 190 | } |
| 191 | return i < len(src) && src[i] == '(' |
| 192 | } |
| 193 | |
| 194 | // decodeRef splits a word already known to match refPattern into a |
| 195 | // zero-based, absolute-flagged reference. A1 notation is one-based, so a |
| 196 | // row literal of 0 (e.g. "A0") is malformed input, not row -1 — it is |
| 197 | // rejected here rather than allowed to flow downstream as a negative |
| 198 | // position. |
| 199 | func decodeRef(w string) (refToken, error) { |
| 200 | idx := 0 |
| 201 | var r refToken |
| 202 | if w[idx] == '$' { |
| 203 | r.absCol = true |
| 204 | idx++ |
| 205 | } |
| 206 | letterStart := idx |
| 207 | for idx < len(w) && isLetter(w[idx]) { |
| 208 | idx++ |
| 209 | } |
| 210 | col, err := ParseColLabel(w[letterStart:idx]) |
| 211 | if err != nil { |
| 212 | return refToken{}, err |
| 213 | } |
| 214 | r.colPos = col |
| 215 | if idx < len(w) && w[idx] == '$' { |
| 216 | r.absRow = true |
| 217 | idx++ |
| 218 | } |
| 219 | row, err := strconv.Atoi(w[idx:]) |
| 220 | if err != nil { |
| 221 | return refToken{}, err |
| 222 | } |
| 223 | if row < 1 { |
| 224 | return refToken{}, fmt.Errorf("calc: row must be positive") |
| 225 | } |
| 226 | r.rowPos = row - 1 |
| 227 | return r, nil |
| 228 | } |
| 229 | |
| 230 | func lexOperator(src string, i int) (token, int, error) { |
| 231 | c := src[i] |
| 232 | switch c { |
| 233 | case '+', '-', '*', '/', '^', '&', '=': |
| 234 | return token{kind: tOp, pos: i, op: string(c)}, i + 1, nil |
| 235 | case '<': |
| 236 | if i+1 < len(src) && src[i+1] == '=' { |
| 237 | return token{kind: tOp, pos: i, op: "<="}, i + 2, nil |
| 238 | } |
| 239 | if i+1 < len(src) && src[i+1] == '>' { |
| 240 | return token{kind: tOp, pos: i, op: "<>"}, i + 2, nil |
| 241 | } |
| 242 | return token{kind: tOp, pos: i, op: "<"}, i + 1, nil |
| 243 | case '>': |
| 244 | if i+1 < len(src) && src[i+1] == '=' { |
| 245 | return token{kind: tOp, pos: i, op: ">="}, i + 2, nil |
| 246 | } |
| 247 | return token{kind: tOp, pos: i, op: ">"}, i + 1, nil |
| 248 | case '(': |
| 249 | return token{kind: tLParen, pos: i}, i + 1, nil |
| 250 | case ')': |
| 251 | return token{kind: tRParen, pos: i}, i + 1, nil |
| 252 | case ',': |
| 253 | return token{kind: tComma, pos: i}, i + 1, nil |
| 254 | case ':': |
| 255 | return token{kind: tColon, pos: i}, i + 1, nil |
| 256 | default: |
| 257 | return token{}, 0, fmt.Errorf("calc: unexpected character at position %d", i) |
| 258 | } |
| 259 | } |
| 260 | |