Formula lexer, AST and precedence-climbing parser
5 files changed,
+596
−0
internal/sheets/calc/ast.go+43 −0internal/sheets/calc/parse.go+194 −0internal/sheets/calc/parse_call.go+34 −0internal/sheets/calc/parse_test.go+89 −0internal/sheets/calc/token.go+236 −0
Intent
diff --git a/internal/sheets/calc/ast.go b/internal/sheets/calc/ast.go| new file mode 100644 |
| index 0000000..256cdf9 |
| --- /dev/null |
| +++ b/internal/sheets/calc/ast.go |
| @@ -0,0 +1,43 @@ |
| +package calc |
| + |
| +// Node is any expression node. |
| +type Node interface{ node() } |
| + |
| +type Num struct{ V float64 } |
| +type Str struct{ V string } |
| +type Bool struct{ V bool } |
| + |
| +// Ref is a single-cell reference as written. Positions are what the source |
| +// says; binding to stable ids happens in the reference pass, because |
| +// relative references are positional offsets and must stay that way until |
| +// a paste anchor is known. |
| +type Ref struct { |
| + ColPos, RowPos int |
| + AbsCol, AbsRow bool |
| +} |
| + |
| +type Range struct{ From, To *Ref } |
| + |
| +type Binary struct { |
| + Op string |
| + Left, Right Node |
| +} |
| + |
| +type Unary struct { |
| + Op string |
| + X Node |
| +} |
| + |
| +type Call struct { |
| + Name string |
| + Args []Node |
| +} |
| + |
| +func (*Num) node() {} |
| +func (*Str) node() {} |
| +func (*Bool) node() {} |
| +func (*Ref) node() {} |
| +func (*Range) node() {} |
| +func (*Binary) node() {} |
| +func (*Unary) node() {} |
| +func (*Call) node() {} |
diff --git a/internal/sheets/calc/parse.go b/internal/sheets/calc/parse.go| new file mode 100644 |
| index 0000000..9018d08 |
| --- /dev/null |
| +++ b/internal/sheets/calc/parse.go |
| @@ -0,0 +1,194 @@ |
| +package calc |
| + |
| +import ( |
| + "fmt" |
| + "strings" |
| +) |
| + |
| +// Parse compiles a formula's source text (without a leading "=") into an |
| +// AST. Errors report only a byte position and a reason; the formula's own |
| +// content never appears in an error string. |
| +// |
| +// Precedence, loosest to tightest: comparison (= <> < <= > >=), then |
| +// concatenation (&), then + -, then * /, then ^ (right-associative), then |
| +// unary minus, then primary. Unary minus binding tighter than ^ matches |
| +// spreadsheet convention (Excel: -2^2 = 4, not -4), which is the reverse |
| +// of most programming languages and is easy to get wrong later, so it is |
| +// pinned here with a test. |
| +func Parse(src string) (Node, error) { |
| + toks, err := lex(src) |
| + if err != nil { |
| + return nil, err |
| + } |
| + p := &parser{toks: toks} |
| + p.cur = toks[0] |
| + |
| + n, err := p.parseComparison() |
| + if err != nil { |
| + return nil, err |
| + } |
| + if p.cur.kind != tEOF { |
| + return nil, p.errorf("unexpected trailing input") |
| + } |
| + return n, nil |
| +} |
| + |
| +type parser struct { |
| + toks []token |
| + pos int |
| + cur token |
| +} |
| + |
| +func (p *parser) advance() { |
| + p.pos++ |
| + if p.pos < len(p.toks) { |
| + p.cur = p.toks[p.pos] |
| + } else { |
| + p.cur = token{kind: tEOF, pos: p.cur.pos} |
| + } |
| +} |
| + |
| +func (p *parser) errorf(reason string) error { |
| + return fmt.Errorf("calc: parse error at position %d: %s", p.cur.pos, reason) |
| +} |
| + |
| +var comparisonOps = map[string]bool{"=": true, "<>": true, "<": true, "<=": true, ">": true, ">=": true} |
| +var concatOps = map[string]bool{"&": true} |
| +var additiveOps = map[string]bool{"+": true, "-": true} |
| +var termOps = map[string]bool{"*": true, "/": true} |
| + |
| +// parseBinaryLevel implements one left-associative precedence level shared |
| +// by comparison, concatenation, addition and multiplication: parse one |
| +// operand via next, then fold in any run of same-level operators. |
| +func (p *parser) parseBinaryLevel(ops map[string]bool, next func() (Node, error)) (Node, error) { |
| + left, err := next() |
| + if err != nil { |
| + return nil, err |
| + } |
| + for p.cur.kind == tOp && ops[p.cur.op] { |
| + op := p.cur.op |
| + p.advance() |
| + right, err := next() |
| + if err != nil { |
| + return nil, err |
| + } |
| + left = &Binary{Op: op, Left: left, Right: right} |
| + } |
| + return left, nil |
| +} |
| + |
| +func (p *parser) parseComparison() (Node, error) { |
| + return p.parseBinaryLevel(comparisonOps, p.parseConcat) |
| +} |
| + |
| +func (p *parser) parseConcat() (Node, error) { |
| + return p.parseBinaryLevel(concatOps, p.parseAdditive) |
| +} |
| + |
| +func (p *parser) parseAdditive() (Node, error) { |
| + return p.parseBinaryLevel(additiveOps, p.parseTerm) |
| +} |
| + |
| +func (p *parser) parseTerm() (Node, error) { |
| + return p.parseBinaryLevel(termOps, p.parsePower) |
| +} |
| + |
| +// parsePower is right-associative: 2^3^2 = 2^(3^2). |
| +func (p *parser) parsePower() (Node, error) { |
| + left, err := p.parseUnary() |
| + if err != nil { |
| + return nil, err |
| + } |
| + if p.cur.kind == tOp && p.cur.op == "^" { |
| + p.advance() |
| + right, err := p.parsePower() |
| + if err != nil { |
| + return nil, err |
| + } |
| + return &Binary{Op: "^", Left: left, Right: right}, nil |
| + } |
| + return left, nil |
| +} |
| + |
| +// parseUnary binds tighter than parsePower: see the precedence note on Parse. |
| +func (p *parser) parseUnary() (Node, error) { |
| + if p.cur.kind == tOp && p.cur.op == "-" { |
| + p.advance() |
| + x, err := p.parseUnary() |
| + if err != nil { |
| + return nil, err |
| + } |
| + return &Unary{Op: "-", X: x}, nil |
| + } |
| + return p.parsePrimary() |
| +} |
| + |
| +func (p *parser) parsePrimary() (Node, error) { |
| + switch p.cur.kind { |
| + case tNum: |
| + v := p.cur.num |
| + p.advance() |
| + return &Num{V: v}, nil |
| + case tStr: |
| + v := p.cur.str |
| + p.advance() |
| + return &Str{V: v}, nil |
| + case tRef: |
| + return p.parseRefOrRange() |
| + case tLParen: |
| + p.advance() |
| + inner, err := p.parseComparison() |
| + if err != nil { |
| + return nil, err |
| + } |
| + if p.cur.kind != tRParen { |
| + return nil, p.errorf("expected closing parenthesis") |
| + } |
| + p.advance() |
| + return inner, nil |
| + case tIdent: |
| + return p.parseIdentOrCall() |
| + default: |
| + return nil, p.errorf("unexpected token, want expression") |
| + } |
| +} |
| + |
| +func (p *parser) parseRefOrRange() (Node, error) { |
| + from := refNode(p.cur.ref) |
| + p.advance() |
| + if p.cur.kind != tColon { |
| + return from, nil |
| + } |
| + p.advance() |
| + if p.cur.kind != tRef { |
| + return nil, p.errorf("expected reference after ':'") |
| + } |
| + to := refNode(p.cur.ref) |
| + p.advance() |
| + return &Range{From: from, To: to}, nil |
| +} |
| + |
| +func refNode(r refToken) *Ref { |
| + return &Ref{ColPos: r.colPos, RowPos: r.rowPos, AbsCol: r.absCol, AbsRow: r.absRow} |
| +} |
| + |
| +func (p *parser) parseIdentOrCall() (Node, error) { |
| + name := p.cur.ident |
| + p.advance() |
| + |
| + switch strings.ToUpper(name) { |
| + case "TRUE": |
| + return &Bool{V: true}, nil |
| + case "FALSE": |
| + return &Bool{V: false}, nil |
| + } |
| + |
| + if p.cur.kind != tLParen { |
| + return nil, p.errorf("unexpected identifier, want function call") |
| + } |
| + args, err := p.parseCallArgs() |
| + if err != nil { |
| + return nil, err |
| + } |
| + return &Call{Name: strings.ToUpper(name), Args: args}, nil |
| +} |
diff --git a/internal/sheets/calc/parse_call.go b/internal/sheets/calc/parse_call.go| new file mode 100644 |
| index 0000000..d723ca8 |
| --- /dev/null |
| +++ b/internal/sheets/calc/parse_call.go |
| @@ -0,0 +1,34 @@ |
| +package calc |
| + |
| +// parseCallArgs parses a parenthesized, comma-separated argument list. The |
| +// current token must be the opening '(' on entry; it consumes through the |
| +// matching ')'. |
| +func (p *parser) parseCallArgs() ([]Node, error) { |
| + p.advance() // consume '(' |
| + |
| + if p.cur.kind == tRParen { |
| + p.advance() |
| + return nil, nil |
| + } |
| + |
| + var args []Node |
| + for { |
| + arg, err := p.parseComparison() |
| + if err != nil { |
| + return nil, err |
| + } |
| + args = append(args, arg) |
| + |
| + if p.cur.kind == tComma { |
| + p.advance() |
| + continue |
| + } |
| + break |
| + } |
| + |
| + if p.cur.kind != tRParen { |
| + return nil, p.errorf("expected closing parenthesis") |
| + } |
| + p.advance() |
| + return args, nil |
| +} |
diff --git a/internal/sheets/calc/parse_test.go b/internal/sheets/calc/parse_test.go| new file mode 100644 |
| index 0000000..524be85 |
| --- /dev/null |
| +++ b/internal/sheets/calc/parse_test.go |
| @@ -0,0 +1,89 @@ |
| +package calc |
| + |
| +import "testing" |
| + |
| +func TestParseArithmeticPrecedence(t *testing.T) { |
| + n, err := Parse("1+2*3") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b, ok := n.(*Binary) |
| + if !ok || b.Op != "+" { |
| + t.Fatalf("root = %T %v, want Binary +", n, n) |
| + } |
| + r, ok := b.Right.(*Binary) |
| + if !ok || r.Op != "*" { |
| + t.Fatal("multiplication did not bind tighter than addition") |
| + } |
| +} |
| + |
| +func TestParseAbsoluteAndRelativeRefs(t *testing.T) { |
| + n, err := Parse("$A$1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + r := n.(*Ref) |
| + if !r.AbsCol || !r.AbsRow { |
| + t.Fatal("$A$1 did not parse as fully absolute") |
| + } |
| + n2, _ := Parse("A1") |
| + r2 := n2.(*Ref) |
| + if r2.AbsCol || r2.AbsRow { |
| + t.Fatal("A1 parsed as absolute") |
| + } |
| + if r2.ColPos != 0 || r2.RowPos != 0 { |
| + t.Fatalf("A1 = col %d row %d, want 0,0", r2.ColPos, r2.RowPos) |
| + } |
| +} |
| + |
| +func TestParseRangeAndCall(t *testing.T) { |
| + n, err := Parse("SUM(A1:B10)") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + c, ok := n.(*Call) |
| + if !ok || c.Name != "SUM" { |
| + t.Fatalf("root = %T, want Call SUM", n) |
| + } |
| + if _, ok := c.Args[0].(*Range); !ok { |
| + t.Fatalf("arg 0 = %T, want Range", c.Args[0]) |
| + } |
| +} |
| + |
| +func TestParseRejectsGarbage(t *testing.T) { |
| + for _, src := range []string{"1+", "(1", "SUM(", "A1:", "*"} { |
| + if _, err := Parse(src); err == nil { |
| + t.Errorf("Parse(%q) succeeded, want error", src) |
| + } |
| + } |
| +} |
| + |
| +func TestParsePowerIsRightAssociative(t *testing.T) { |
| + // 2^3^2 must parse as 2^(3^2) = 2^9 = 512, not (2^3)^2 = 64. |
| + n, err := Parse("2^3^2") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b, ok := n.(*Binary) |
| + if !ok || b.Op != "^" { |
| + t.Fatalf("root = %T %v, want Binary ^", n, n) |
| + } |
| + if _, ok := b.Left.(*Num); !ok { |
| + t.Fatalf("left = %T, want Num (right-associative grouping)", b.Left) |
| + } |
| + r, ok := b.Right.(*Binary) |
| + if !ok || r.Op != "^" { |
| + t.Fatal("right operand should itself be a ^ binary (right-associative)") |
| + } |
| +} |
| + |
| +func TestParseFunctionNameCaseInsensitive(t *testing.T) { |
| + n, err := Parse("sum(A1)") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + c, ok := n.(*Call) |
| + if !ok || c.Name != "SUM" { |
| + t.Fatalf("root = %T %v, want Call SUM (normalised to upper case)", n, n) |
| + } |
| +} |
diff --git a/internal/sheets/calc/token.go b/internal/sheets/calc/token.go| new file mode 100644 |
| index 0000000..6eefb2c |
| --- /dev/null |
| +++ b/internal/sheets/calc/token.go |
| @@ -0,0 +1,236 @@ |
| +package calc |
| + |
| +import ( |
| + "fmt" |
| + "regexp" |
| + "strconv" |
| + "strings" |
| +) |
| + |
| +// tokKind classifies a lexed token. Formula text itself never appears in |
| +// error messages built around these tokens — only positions and reasons — |
| +// because a formula can carry sensitive cell content. |
| +type tokKind int |
| + |
| +const ( |
| + tEOF tokKind = iota |
| + tNum |
| + tStr |
| + tIdent |
| + tRef |
| + tOp |
| + tLParen |
| + tRParen |
| + tComma |
| + tColon |
| +) |
| + |
| +type token struct { |
| + kind tokKind |
| + pos int // byte offset in the source, for error reporting only |
| + |
| + num float64 |
| + str string |
| + ident string |
| + op string |
| + ref refToken |
| +} |
| + |
| +// refToken is a lexed single-cell reference, decoded but not yet an AST |
| +// node (the parser wraps it in *Ref or joins two into *Range). |
| +type refToken struct { |
| + colPos, rowPos int |
| + absCol, absRow bool |
| +} |
| + |
| +var refPattern = regexp.MustCompile(`^\$?[A-Za-z]{1,3}\$?[0-9]+$`) |
| + |
| +// lex scans src into a token stream terminated by a single tEOF token. |
| +func lex(src string) ([]token, error) { |
| + var toks []token |
| + i := 0 |
| + n := len(src) |
| + for i < n { |
| + c := src[i] |
| + switch { |
| + case c == ' ' || c == '\t' || c == '\n' || c == '\r': |
| + i++ |
| + case isDigit(c) || (c == '.' && i+1 < n && isDigit(src[i+1])): |
| + tok, next, err := lexNumber(src, i) |
| + if err != nil { |
| + return nil, err |
| + } |
| + toks = append(toks, tok) |
| + i = next |
| + case c == '"': |
| + tok, next, err := lexString(src, i) |
| + if err != nil { |
| + return nil, err |
| + } |
| + toks = append(toks, tok) |
| + i = next |
| + case isLetter(c) || c == '$': |
| + tok, next, err := lexWord(src, i) |
| + if err != nil { |
| + return nil, err |
| + } |
| + toks = append(toks, tok) |
| + i = next |
| + default: |
| + tok, next, err := lexOperator(src, i) |
| + if err != nil { |
| + return nil, err |
| + } |
| + toks = append(toks, tok) |
| + i = next |
| + } |
| + } |
| + toks = append(toks, token{kind: tEOF, pos: n}) |
| + return toks, nil |
| +} |
| + |
| +func isDigit(c byte) bool { return c >= '0' && c <= '9' } |
| + |
| +func isLetter(c byte) bool { |
| + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') |
| +} |
| + |
| +func isWordChar(c byte) bool { |
| + return isLetter(c) || isDigit(c) || c == '$' |
| +} |
| + |
| +func lexNumber(src string, start int) (token, int, error) { |
| + i := start |
| + n := len(src) |
| + for i < n && isDigit(src[i]) { |
| + i++ |
| + } |
| + if i < n && src[i] == '.' { |
| + i++ |
| + for i < n && isDigit(src[i]) { |
| + i++ |
| + } |
| + } |
| + if i < n && (src[i] == 'e' || src[i] == 'E') { |
| + j := i + 1 |
| + if j < n && (src[j] == '+' || src[j] == '-') { |
| + j++ |
| + } |
| + if j < n && isDigit(src[j]) { |
| + i = j |
| + for i < n && isDigit(src[i]) { |
| + i++ |
| + } |
| + } |
| + } |
| + v, err := strconv.ParseFloat(src[start:i], 64) |
| + if err != nil { |
| + return token{}, 0, fmt.Errorf("calc: malformed number literal at position %d", start) |
| + } |
| + return token{kind: tNum, pos: start, num: v}, i, nil |
| +} |
| + |
| +func lexString(src string, start int) (token, int, error) { |
| + i := start + 1 |
| + n := len(src) |
| + var sb strings.Builder |
| + for { |
| + if i >= n { |
| + return token{}, 0, fmt.Errorf("calc: unterminated string literal starting at position %d", start) |
| + } |
| + if src[i] == '"' { |
| + if i+1 < n && src[i+1] == '"' { |
| + sb.WriteByte('"') |
| + i += 2 |
| + continue |
| + } |
| + i++ |
| + break |
| + } |
| + sb.WriteByte(src[i]) |
| + i++ |
| + } |
| + return token{kind: tStr, pos: start, str: sb.String()}, i, nil |
| +} |
| + |
| +func lexWord(src string, start int) (token, int, error) { |
| + i := start |
| + n := len(src) |
| + for i < n && isWordChar(src[i]) { |
| + i++ |
| + } |
| + w := src[start:i] |
| + |
| + if refPattern.MatchString(w) { |
| + ref, err := decodeRef(w) |
| + if err != nil { |
| + return token{}, 0, fmt.Errorf("calc: malformed reference at position %d", start) |
| + } |
| + return token{kind: tRef, pos: start, ref: ref}, i, nil |
| + } |
| + if strings.Contains(w, "$") { |
| + return token{}, 0, fmt.Errorf("calc: malformed reference at position %d", start) |
| + } |
| + return token{kind: tIdent, pos: start, ident: w}, i, nil |
| +} |
| + |
| +// decodeRef splits a word already known to match refPattern into a |
| +// zero-based, absolute-flagged reference. |
| +func decodeRef(w string) (refToken, error) { |
| + idx := 0 |
| + var r refToken |
| + if w[idx] == '$' { |
| + r.absCol = true |
| + idx++ |
| + } |
| + letterStart := idx |
| + for idx < len(w) && isLetter(w[idx]) { |
| + idx++ |
| + } |
| + col, err := ParseColLabel(w[letterStart:idx]) |
| + if err != nil { |
| + return refToken{}, err |
| + } |
| + r.colPos = col |
| + if idx < len(w) && w[idx] == '$' { |
| + r.absRow = true |
| + idx++ |
| + } |
| + row, err := strconv.Atoi(w[idx:]) |
| + if err != nil { |
| + return refToken{}, err |
| + } |
| + r.rowPos = row - 1 |
| + return r, nil |
| +} |
| + |
| +func lexOperator(src string, i int) (token, int, error) { |
| + c := src[i] |
| + switch c { |
| + case '+', '-', '*', '/', '^', '&', '=': |
| + return token{kind: tOp, pos: i, op: string(c)}, i + 1, nil |
| + case '<': |
| + if i+1 < len(src) && src[i+1] == '=' { |
| + return token{kind: tOp, pos: i, op: "<="}, i + 2, nil |
| + } |
| + if i+1 < len(src) && src[i+1] == '>' { |
| + return token{kind: tOp, pos: i, op: "<>"}, i + 2, nil |
| + } |
| + return token{kind: tOp, pos: i, op: "<"}, i + 1, nil |
| + case '>': |
| + if i+1 < len(src) && src[i+1] == '=' { |
| + return token{kind: tOp, pos: i, op: ">="}, i + 2, nil |
| + } |
| + return token{kind: tOp, pos: i, op: ">"}, i + 1, nil |
| + case '(': |
| + return token{kind: tLParen, pos: i}, i + 1, nil |
| + case ')': |
| + return token{kind: tRParen, pos: i}, i + 1, nil |
| + case ',': |
| + return token{kind: tComma, pos: i}, i + 1, nil |
| + case ':': |
| + return token{kind: tColon, pos: i}, i + 1, nil |
| + default: |
| + return token{}, 0, fmt.Errorf("calc: unexpected character at position %d", i) |
| + } |
| +} |