Find returns matching cells in reading order with Sheets' options: match case, match entire cell, regular expressions and searching within formulas, optionally limited to a range. By default formula cells match on their results; replacing only rewrites formulas when searching within them. Plain replacements are literal, regex ones may use $1 groups, text forced with ' stays text, and a replacement that breaks a formula is rejected without changing the cell.
- commit
- 825d7ad7308087f806bc403a345d703507ad6160
- parent
- 4a499d1411e682de940ade9f4c8e72917bc9191b
- author
- Dami <dami.etoile@gmail.com>
| A | internal/ | 131 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 111 | +++++++++++++++++++++++++ |
@@ -0,0 +1,131 @@
1+package sheet2+3+import (4+ "regexp"5+ "strings"6+)7+8+// FindOptions mirror Google Sheets' Find and replace dialog.9+type FindOptions struct {10+ MatchCase bool11+ WholeCell bool // the whole cell must match, not just part of it12+ Regex bool // the query is a regular expression; replacements may use $113+ InFormulas bool // search formula text instead of formula results14+ Within *Rect // limit the search to a range; nil searches the sheet15+}16+17+// finder is a compiled query.18+type finder struct {19+ re *regexp.Regexp20+ o FindOptions21+}22+23+func newFinder(query string, o FindOptions) (*finder, error) {24+ pat := query25+ if !o.Regex {26+ pat = regexp.QuoteMeta(query)27+ }28+ if o.WholeCell {29+ pat = "^(?:" + pat + ")$"30+ }31+ if !o.MatchCase {32+ pat = "(?i)" + pat33+ }34+ re, err := regexp.Compile(pat)35+ if err != nil {36+ return nil, err37+ }38+ return &finder{re: re, o: o}, nil39+}40+41+// text is what the search looks at in a cell: formula text when searching42+// within formulas, otherwise what the cell shows.43+func (f *finder) text(c *Cell) string {44+ if c.IsFormula() && f.o.InFormulas {45+ return c.Input46+ }47+ if c.IsFormula() {48+ return c.Value.String()49+ }50+ return strings.TrimPrefix(c.Input, "'")51+}52+53+// Find returns the cells matching query in reading order: row by row,54+// left to right.55+func (s *Sheet) Find(query string, o FindOptions) ([]Addr, error) {56+ if query == "" {57+ return nil, nil58+ }59+ f, err := newFinder(query, o)60+ if err != nil {61+ return nil, err62+ }63+ var out []Addr64+ for _, a := range s.Addrs() {65+ if o.Within != nil && !o.Within.Contains(a) {66+ continue67+ }68+ if f.re.MatchString(f.text(s.cells[a])) {69+ out = append(out, a)70+ }71+ }72+ return out, nil73+}74+75+// Replace replaces matches of query in the cell at a and reports whether76+// the cell changed. Formula cells are only changed when searching within77+// formulas, as in Sheets. A replacement that turns a formula invalid is78+// returned as an error and leaves the cell unchanged.79+func (s *Sheet) Replace(a Addr, query, repl string, o FindOptions) (bool, error) {80+ f, err := newFinder(query, o)81+ if err != nil {82+ return false, err83+ }84+ return s.replace(a, f, repl)85+}86+87+func (s *Sheet) replace(a Addr, f *finder, repl string) (bool, error) {88+ c := s.cells[a]89+ if c == nil || (c.IsFormula() && !f.o.InFormulas) {90+ return false, nil91+ }92+ old := f.text(c)93+ if !f.re.MatchString(old) {94+ return false, nil95+ }96+ if !f.o.Regex {97+ repl = strings.ReplaceAll(repl, "$", "$$")98+ }99+ next := f.re.ReplaceAllString(old, repl)100+ if strings.HasPrefix(c.Input, "'") && !c.IsFormula() {101+ next = "'" + next // keep text forced as text102+ }103+ if next == c.Input {104+ return false, nil105+ }106+ return true, s.Set(a, next)107+}108+109+// ReplaceAll replaces every match and returns how many cells changed. It110+// stops at the first cell whose replacement is an invalid formula.111+func (s *Sheet) ReplaceAll(query, repl string, o FindOptions) (int, error) {112+ cells, err := s.Find(query, o)113+ if err != nil {114+ return 0, err115+ }116+ f, err := newFinder(query, o)117+ if err != nil {118+ return 0, err119+ }120+ n := 0121+ for _, a := range cells {122+ changed, err := s.replace(a, f, repl)123+ if err != nil {124+ return n, err125+ }126+ if changed {127+ n++128+ }129+ }130+ return n, nil131+}@@ -0,0 +1,111 @@
1+package sheet2+3+import (4+ "slices"5+ "testing"6+)7+8+func findSheet() *Sheet {9+ s := New()10+ for a, in := range map[string]string{11+ "A1": "Rent", "B1": "1450",12+ "A2": "rental car", "B2": "=B1*2",13+ "A3": "'2900", "B3": "=SUM(B1:B2)",14+ "C1": "Rent", "C2": "Total rent: $5",15+ } {16+ s.Set(at(a), in)17+ }18+ return s19+}20+21+func names(as []Addr) []string {22+ out := make([]string, len(as))23+ for i, a := range as {24+ out[i] = a.String()25+ }26+ return out27+}28+29+func TestFind(t *testing.T) {30+ s := findSheet()31+ b1b2 := NewRect(at("B1"), at("B2"))32+ tests := []struct {33+ query string34+ o FindOptions35+ want []string36+ }{37+ {"rent", FindOptions{}, []string{"A1", "C1", "A2", "C2"}}, // reading order38+ {"Rent", FindOptions{MatchCase: true}, []string{"A1", "C1"}},39+ {"rent", FindOptions{WholeCell: true}, []string{"A1", "C1"}},40+ {"2900", FindOptions{}, []string{"B2", "A3"}}, // formula results and forced text41+ {"B1", FindOptions{}, nil}, // formula text isn't searched by default42+ {"B1", FindOptions{InFormulas: true}, []string{"B2", "B3"}},43+ {`^r\w+`, FindOptions{Regex: true}, []string{"A1", "C1", "A2"}},44+ {"$5", FindOptions{}, []string{"C2"}}, // plain queries are literal45+ {"1450", FindOptions{Within: &b1b2}, []string{"B1"}},46+ {"", FindOptions{}, nil},47+ }48+ for _, tt := range tests {49+ got, err := s.Find(tt.query, tt.o)50+ if err != nil {51+ t.Errorf("Find(%q): %v", tt.query, err)52+ continue53+ }54+ if !slices.Equal(names(got), tt.want) {55+ t.Errorf("Find(%q, %+v) = %v, want %v", tt.query, tt.o, names(got), tt.want)56+ }57+ }58+ if _, err := s.Find("(", FindOptions{Regex: true}); err == nil {59+ t.Error("invalid regex accepted")60+ }61+}62+63+func TestReplace(t *testing.T) {64+ s := findSheet()65+ n, err := s.ReplaceAll("rent", "Lease", FindOptions{})66+ if err != nil || n != 4 {67+ t.Fatalf("ReplaceAll = %d, %v", n, err)68+ }69+ for a, want := range map[string]string{"A1": "Lease", "A2": "Leaseal car", "C2": "Total Lease: $5"} {70+ if got := s.Cell(at(a)).Input; got != want {71+ t.Errorf("%s = %q, want %q", a, got, want)72+ }73+ }74+75+ // Formulas only change when searching within them, and keep working.76+ if n, _ := s.ReplaceAll("B1", "A9", FindOptions{}); n != 0 {77+ t.Errorf("replaced %d formula results", n)78+ }79+ s.Set(at("A9"), "10")80+ if n, _ := s.ReplaceAll("B1", "A9", FindOptions{InFormulas: true}); n != 2 {81+ t.Errorf("replaced %d formulas", n)82+ }83+ if got := s.Value(at("B2")).Num; got != 20 {84+ t.Errorf("B2 = %v after rewriting its reference", got)85+ }86+87+ // Regex groups; forced text stays text; literal $ in plain mode.88+ s.Set(at("D1"), "2026-09-26")89+ s.Replace(at("D1"), `(\d+)-(\d+)-(\d+)`, "$3/$2/$1", FindOptions{Regex: true})90+ if got := s.Cell(at("D1")).Input; got != "26/09/2026" {91+ t.Errorf("regex replace = %q", got)92+ }93+ s.Replace(at("A3"), "2900", "3000", FindOptions{})94+ if c := s.Cell(at("A3")); c.Input != "'3000" || c.Value.Kind != Text {95+ t.Errorf("forced text = %+v", c)96+ }97+ s.Set(at("C1"), "Rent due")98+ s.Replace(at("C1"), "Rent", "$1", FindOptions{})99+ if got := s.Cell(at("C1")).Input; got != "$1 due" {100+ t.Errorf("plain replacement = %q, want a literal $1", got)101+ }102+103+ // A replacement that breaks a formula is an error and changes nothing.104+ s.Set(at("E1"), "=SUM(B1:B2)")105+ if _, err := s.Replace(at("E1"), ")", "", FindOptions{InFormulas: true}); err == nil {106+ t.Error("broken formula accepted")107+ }108+ if got := s.Cell(at("E1")).Input; got != "=SUM(B1:B2)" {109+ t.Errorf("E1 changed to %q", got)110+ }111+}