oficina / sheets Public

Reference translation: the pass stable ids do not remove

Paul Campbell df19b0d7f55767be7f247f5996cd15cecb31d3db parent 883442d
2 files changed, +189 −0
  • internal/sheets/calc/translate.go +124 −0
  • internal/sheets/calc/translate_test.go +65 −0
diff --git a/internal/sheets/calc/translate.go b/internal/sheets/calc/translate.go
new file mode 100644
index 0000000..4a51561
--- /dev/null
+++ b/internal/sheets/calc/translate.go
@@ -0,0 +1,124 @@
+package calc
+
+import "strconv"
+
+// Translate offsets every relative reference in n by (dRow, dCol) and
+// returns a new tree. Absolute components are untouched — that is the whole
+// meaning of the dollar sign.
+//
+// This is the pass stable row and column identity does NOT remove. Ids make
+// insert and delete free; copy and fill are positional by definition, so the
+// offset has to be applied at paste time. See spec section 4.2.
+func Translate(n Node, dRow, dCol int) Node {
+ switch t := n.(type) {
+ case *Ref:
+ out := *t
+ if !out.AbsRow {
+ out.RowPos += dRow
+ }
+ if !out.AbsCol {
+ out.ColPos += dCol
+ }
+ return &out
+ case *Range:
+ return &Range{
+ From: Translate(t.From, dRow, dCol).(*Ref),
+ To: Translate(t.To, dRow, dCol).(*Ref),
+ }
+ case *Binary:
+ return &Binary{Op: t.Op,
+ Left: Translate(t.Left, dRow, dCol),
+ Right: Translate(t.Right, dRow, dCol)}
+ case *Unary:
+ return &Unary{Op: t.Op, X: Translate(t.X, dRow, dCol)}
+ case *Call:
+ args := make([]Node, len(t.Args))
+ for i, a := range t.Args {
+ args[i] = Translate(a, dRow, dCol)
+ }
+ return &Call{Name: t.Name, Args: args}
+ default:
+ return n // Num, Str, Bool have no references
+ }
+}
+
+// Render is the inverse of Parse for the node shapes Parse can produce:
+// numbers, strings (re-quoted with `"` doubled), booleans, references and
+// ranges (with both absolute markers preserved in position), unary minus,
+// left-associative binary operators, and upper-cased function calls. It
+// does not attempt to reproduce the source's original parenthesisation or
+// whitespace — only a re-parseable, semantically equivalent formula. This
+// is also what the formula bar shows, so the round trip on absolute
+// markers matters beyond the tests: a Render that dropped a "$" would
+// silently turn an absolute reference into a relative one the next time it
+// was copied.
+func Render(n Node) string {
+ switch t := n.(type) {
+ case *Num:
+ return strconv.FormatFloat(t.V, 'g', -1, 64)
+ case *Str:
+ return `"` + quoteStr(t.V) + `"`
+ case *Bool:
+ if t.V {
+ return "TRUE"
+ }
+ return "FALSE"
+ case *Ref:
+ return renderRef(t)
+ case *Range:
+ return renderRef(t.From) + ":" + renderRef(t.To)
+ case *Unary:
+ return t.Op + Render(t.X)
+ case *Binary:
+ return Render(t.Left) + t.Op + Render(t.Right)
+ case *Call:
+ args := make([]string, len(t.Args))
+ for i, a := range t.Args {
+ args[i] = Render(a)
+ }
+ return t.Name + "(" + joinComma(args) + ")"
+ default:
+ return ""
+ }
+}
+
+// renderRef reproduces a single reference's A1 text, including whichever
+// dollar signs are set: neither ($A1 has one, A$1 has the other, $A$1 has
+// both), matching how decodeRef reads them back.
+func renderRef(r *Ref) string {
+ s := ""
+ if r.AbsCol {
+ s += "$"
+ }
+ s += ColLabel(r.ColPos)
+ if r.AbsRow {
+ s += "$"
+ }
+ s += strconv.Itoa(r.RowPos + 1)
+ return s
+}
+
+// quoteStr doubles embedded double quotes, the inverse of lexString's
+// unescaping.
+func quoteStr(s string) string {
+ out := make([]byte, 0, len(s))
+ for i := 0; i < len(s); i++ {
+ if s[i] == '"' {
+ out = append(out, '"', '"')
+ continue
+ }
+ out = append(out, s[i])
+ }
+ return string(out)
+}
+
+func joinComma(parts []string) string {
+ out := ""
+ for i, p := range parts {
+ if i > 0 {
+ out += ","
+ }
+ out += p
+ }
+ return out
+}
diff --git a/internal/sheets/calc/translate_test.go b/internal/sheets/calc/translate_test.go
new file mode 100644
index 0000000..4afe60c
--- /dev/null
+++ b/internal/sheets/calc/translate_test.go
@@ -0,0 +1,65 @@
+package calc
+
+import "testing"
+
+func TestTranslateRelativeRef(t *testing.T) {
+ n, _ := Parse("A1")
+ got := Translate(n, 1, 0).(*Ref) // copied down one row
+ if got.RowPos != 1 || got.ColPos != 0 {
+ t.Fatalf("=A1 copied down = row %d col %d, want 1,0", got.RowPos, got.ColPos)
+ }
+}
+
+func TestTranslateLeavesAbsoluteAlone(t *testing.T) {
+ n, _ := Parse("$A$1")
+ got := Translate(n, 5, 5).(*Ref)
+ if got.RowPos != 0 || got.ColPos != 0 {
+ t.Fatal("$A$1 moved under translation")
+ }
+}
+
+func TestTranslateMixedRef(t *testing.T) {
+ n, _ := Parse("$A1") // absolute column, relative row
+ got := Translate(n, 3, 4).(*Ref)
+ if got.ColPos != 0 {
+ t.Fatal("$A moved")
+ }
+ if got.RowPos != 3 {
+ t.Fatalf("relative row = %d, want 3", got.RowPos)
+ }
+}
+
+func TestTranslateDescendsIntoCalls(t *testing.T) {
+ n, _ := Parse("SUM(A1:A5)+$B$1")
+ got := Translate(n, 2, 0).(*Binary)
+ rng := got.Left.(*Call).Args[0].(*Range)
+ if rng.From.RowPos != 2 || rng.To.RowPos != 6 {
+ t.Fatalf("range = %d:%d, want 2:6", rng.From.RowPos, rng.To.RowPos)
+ }
+ if got.Right.(*Ref).RowPos != 0 {
+ t.Fatal("$B$1 moved")
+ }
+}
+
+// Each row restates spec section 4.2. If an implementation changes one of
+// these behaviours, this table is what it has to argue with.
+func TestReferenceBehaviourPerOperation(t *testing.T) {
+ cases := []struct {
+ op, src, want string
+ dRow, dCol int
+ }{
+ {op: "copy down", src: "A1", dRow: 1, want: "A2"},
+ {op: "copy right", src: "A1", dCol: 1, want: "B1"},
+ {op: "copy absolute", src: "$A$1", dRow: 9, dCol: 9, want: "$A$1"},
+ {op: "fill range", src: "SUM(A1:A3)", dRow: 1, want: "SUM(A2:A4)"},
+ }
+ for _, tc := range cases {
+ n, err := Parse(tc.src)
+ if err != nil {
+ t.Fatalf("%s: parse %q: %v", tc.op, tc.src, err)
+ }
+ if got := Render(Translate(n, tc.dRow, tc.dCol)); got != tc.want {
+ t.Errorf("%s: %q -> %q, want %q", tc.op, tc.src, got, tc.want)
+ }
+ }
+}