- commit
- 0be2a2814b4a478219a9e4e98c43fc083b90207d
- parent
- 429c8147c06872f566ae5739c171c6f9b91620a1
- author
- Dami <dami.etoile@gmail.com>
| A | internal/ | 133 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 83 | +++++++++++++++++++ |
| A | internal/ | 64 | ++++++++++++++ |
@@ -0,0 +1,133 @@
1+package sheet2+3+import (4+ "slices"5+ "strings"6+)7+8+// ExplainError says why the cell at a shows an error, for the context9+// line: where the error starts and what went wrong, e.g. "Division by10+// zero in B3/0", or "From B5: division by zero in B3/0" when it comes from11+// another cell. It is empty for cells without errors.12+func (s *Sheet) ExplainError(a Addr) string {13+ return s.explain(a, nil)14+}15+16+// maxExplainExpr caps how much of a formula an explanation quotes.17+const maxExplainExpr = 4018+19+func (s *Sheet) explain(a Addr, path []Addr) string {20+ c := s.cells[a]21+ v := s.Value(a)22+ if c == nil || v.Kind != Error || !c.IsFormula() {23+ return ""24+ }25+ if i := slices.Index(path, a); i >= 0 {26+ names := make([]string, 0, len(path)-i+1)27+ for _, p := range path[i:] {28+ names = append(names, p.String())29+ }30+ return "Circular reference: " + strings.Join(append(names, a.String()), " → ")31+ }32+ path = append(path, a)33+ n, from := s.errorOrigin(c.expr, v)34+ if from != nil {35+ why := s.explain(*from, path)36+ if why == "" {37+ return "From " + from.String()38+ }39+ if strings.HasPrefix(why, "Circular") || strings.HasPrefix(why, "From ") {40+ return why41+ }42+ return "From " + from.String() + ": " + strings.ToLower(why[:1]) + why[1:]43+ }44+ return describeError(v, n)45+}46+47+// errorOrigin finds the innermost part of n that produces the error want48+// itself rather than passing it on. When the error comes from a49+// referenced cell, it returns that cell.50+func (s *Sheet) errorOrigin(n Node, want Value) (Node, *Addr) {51+ same := func(v Value) bool { return v.Kind == Error && v.Str == want.Str }52+ get := s.Value53+ switch n := n.(type) {54+ case refNode:55+ if same(get(n.a)) {56+ return n, &n.a57+ }58+ case rangeNode:59+ if n.r.From == n.r.To && same(get(n.r.From)) {60+ return n, &n.r.From61+ }62+ case unaryNode:63+ if same(eval(n.x, get)) {64+ return s.errorOrigin(n.x, want)65+ }66+ case binaryNode:67+ for _, x := range []Node{n.l, n.r} {68+ if same(eval(x, get)) {69+ return s.errorOrigin(x, want)70+ }71+ }72+ case callNode:73+ if n.fn.remote != nil {74+ return n, nil // JEV explains its own answers75+ }76+ for _, arg := range n.args {77+ if r, ok := arg.(rangeNode); ok && r.r.From != r.r.To {78+ cells := s.cellsIn(r.r)79+ sortAddrs(cells)80+ for _, a := range cells {81+ if same(get(a)) {82+ return arg, &a83+ }84+ }85+ continue86+ }87+ if same(eval(arg, get)) {88+ return s.errorOrigin(arg, want)89+ }90+ }91+ }92+ return n, nil93+}94+95+// lookupFuncs report #N/A when they find no match.96+var lookupFuncs = []string{"VLOOKUP", "HLOOKUP", "XLOOKUP", "LOOKUP", "MATCH", "XMATCH"}97+98+// describeError explains an error code produced by n.99+func describeError(v Value, n Node) string {100+ var b strings.Builder101+ printNode(&b, n)102+ expr := b.String()103+ if r := []rune(expr); len(r) > maxExplainExpr {104+ expr = string(r[:maxExplainExpr-1]) + "…"105+ }106+ switch v.Str {107+ case ErrDiv0.Str:108+ return "Division by zero in " + expr109+ case ErrValue.Str:110+ if _, ok := n.(rangeNode); ok {111+ return "A range where one value is expected: " + expr112+ }113+ return "Wrong type of value in " + expr114+ case ErrName.Str:115+ if nm, ok := n.(nameNode); ok {116+ return "Unknown name " + nm.name117+ }118+ return "Unknown name in " + expr119+ case ErrNA.Str:120+ if call, ok := n.(callNode); ok && slices.Contains(lookupFuncs, call.fn.Name) {121+ return "No match found by " + expr122+ }123+ return "Value not available in " + expr124+ case ErrNum.Str:125+ return "Number out of range in " + expr126+ case ErrRef.Str:127+ if _, ok := n.(refErrNode); ok {128+ return "Reference to deleted cells"129+ }130+ return "Invalid reference in " + expr131+ }132+ return "Error in " + expr133+}@@ -0,0 +1,83 @@
1+package sheet2+3+import "testing"4+5+func TestHyperlink(t *testing.T) {6+ checkFormulas(t, fixture(t), []fnCase{7+ {`=HYPERLINK("https://example.com")`, txt("https://example.com")},8+ {`=HYPERLINK("https://example.com", "Example")`, txt("Example")},9+ {`=HYPERLINK("https://example.com", "")`, txt("https://example.com")},10+ {`=HYPERLINK(1/0, "x")`, ErrDiv0},11+ })12+}13+14+func TestLink(t *testing.T) {15+ s := sheetOf(t, map[string]string{16+ "A1": "https://example.com/a?b=1",17+ "A2": "mailto:ann@example.com",18+ "A3": "see https://example.com",19+ "A4": "http://",20+ "A5": `=HYPERLINK("example.com", "Example")`,21+ "A6": `=HYPERLINK(A1, "Docs")`,22+ "A7": `=HYPERLINK("ftp://x", "x")`,23+ "A8": `=UPPER(A1)`,24+ "A9": "12",25+ "B1": `="https://"&"example.com"`,26+ })27+ tests := map[string]string{28+ "A1": "https://example.com/a?b=1",29+ "A2": "mailto:ann@example.com",30+ "A3": "",31+ "A4": "",32+ "A5": "https://example.com",33+ "A6": "https://example.com/a?b=1",34+ "A7": "",35+ "A8": "HTTPS://EXAMPLE.COM/A?B=1",36+ "A9": "",37+ "B1": "https://example.com",38+ "C1": "",39+ }40+ for a, want := range tests {41+ if got := s.Link(at(a)); got != want {42+ t.Errorf("Link(%s) = %q, want %q", a, got, want)43+ }44+ }45+}46+47+func TestExplainError(t *testing.T) {48+ s := sheetOf(t, map[string]string{49+ "A1": "10", "A2": "0", "A3": "text",50+ "B1": "=A1/A2",51+ "B2": "=B1+1",52+ "B3": "=SUM(A1:B2)",53+ "B4": "=A1+A3",54+ "B5": "=FOO+1",55+ "B6": `=VLOOKUP(99, A1:A2, 1, FALSE)`,56+ "B7": "=SQRT(-1)",57+ "B8": "=C8", "C8": "=B8",58+ "B9": "=A1:A2",59+ "B10": "=IFERROR(B1, 0)",60+ "B11": "=AVERAGE(A3)",61+ "B12": "5",62+ })63+ tests := map[string]string{64+ "B1": "Division by zero in A1/A2",65+ "B2": "From B1: division by zero in A1/A2",66+ "B3": "From B1: division by zero in A1/A2",67+ "B4": "Wrong type of value in A1+A3",68+ "B5": "Unknown name FOO",69+ "B6": "No match found by VLOOKUP(99,A1:A2,1,FALSE)",70+ "B7": "Number out of range in SQRT(-1)",71+ "B8": "Circular reference: B8 → C8 → B8",72+ "B9": "A range where one value is expected: A1:A2",73+ "B10": "",74+ "B11": "Division by zero in AVERAGE(A3)",75+ "B12": "",76+ "A3": "",77+ }78+ for a, want := range tests {79+ if got := s.ExplainError(at(a)); got != want {80+ t.Errorf("ExplainError(%s) = %q, want %q", a, got, want)81+ }82+ }83+}@@ -0,0 +1,64 @@
1+package sheet2+3+import "strings"4+5+func init() {6+ define(&FuncDef{Name: "HYPERLINK", Args: "url, [link_label]", Desc: "A link that opens url, shown as its label", Min: 1, Max: 2,7+ eval: func(args []Node, get lookup) Value {8+ url, err := textArg(args[0], get)9+ if err != nil {10+ return *err11+ }12+ if given(args, 1) {13+ label, err := textArg(args[1], get)14+ if err != nil {15+ return *err16+ }17+ if label != "" {18+ return Value{Kind: Text, Str: label}19+ }20+ }21+ return Value{Kind: Text, Str: url}22+ }})23+}24+25+// Link returns the address a cell links to: its text when that is a URL26+// (http, https or mailto), or the target of a HYPERLINK formula. It is27+// empty for other cells.28+func (s *Sheet) Link(a Addr) string {29+ c := s.cells[a]30+ if c == nil || c.Value.Kind != Text {31+ return ""32+ }33+ if call, ok := c.expr.(callNode); ok && call.fn.Name == "HYPERLINK" && c.IsFormula() {34+ v := eval(call.args[0], s.Value)35+ if v.Kind == Error {36+ return ""37+ }38+ return linkTarget(text(v), true)39+ }40+ return linkTarget(c.Value.Str, false)41+}42+43+// linkTarget returns s as a link, or "" if it isn't one. Text in a cell44+// must be a whole URL with a scheme; HYPERLINK also takes a bare domain,45+// as Sheets does, and links it over https.46+func linkTarget(s string, bare bool) string {47+ s = strings.TrimSpace(s)48+ if s == "" || strings.ContainsAny(s, " \t\n\x1b\x07") {49+ return ""50+ }51+ lower := strings.ToLower(s)52+ for _, scheme := range []string{"https://", "http://", "mailto:"} {53+ if strings.HasPrefix(lower, scheme) {54+ if len(s) == len(scheme) {55+ return ""56+ }57+ return s58+ }59+ }60+ if bare && strings.Contains(s, ".") && !strings.Contains(s, ":") {61+ return "https://" + s62+ }63+ return ""64+}