package pdfdoc import ( "fmt" "regexp" "strconv" "strings" "unicode/utf8" "git.wntrmute.dev/kyle/sift/internal/pdftext" ) // maxGrepMatches bounds how many match blocks are shown; the count reported is // always the true total. const maxGrepMatches = 100 // maxGrepPatternBytes bounds a grep pattern; a longer one is rejected before it // can compile. const maxGrepPatternBytes = 200 // hit is one match: the 1-based page and the 0-based line index within it. type hit struct{ page, line int } // renderGrep searches every line of every page in the range (all pages when no // range is given) and returns the matches with their page numbers. func renderGrep(doc *pdftext.Doc, opt Options) (Rendered, error) { pattern := opt.Grep if len(pattern) > maxGrepPatternBytes { return Rendered{}, fmt.Errorf("%w: pattern over %d bytes", ErrBadGrep, maxGrepPatternBytes) } re, err := regexp.Compile(grepPattern(pattern)) if err != nil { return Rendered{}, fmt.Errorf("%w: %v", ErrBadGrep, err) } start, end := 1, len(doc.Text) if opt.Pages != "" { s, e, err := parsePages(opt.Pages, len(doc.Text)) if err != nil { return Rendered{}, err } start, end = s, e } var hits []hit linesByPage := make(map[int][]string, end-start+1) for p := start; p <= end; p++ { lines := splitLines(doc.Text[p-1]) linesByPage[p] = lines for j := range lines { if re.MatchString(lines[j]) { hits = append(hits, hit{p, j}) } } } n := len(hits) var header string switch { case n == 0: header = `grep "` + pattern + `": no matches` case n == 1: header = `grep "` + pattern + `": 1 match` default: header = fmt.Sprintf(`grep "%s": %d matches`, pattern, n) } if n > maxGrepMatches { header += ", showing the first " + strconv.Itoa(maxGrepMatches) } maxBlocks := n if maxBlocks > maxGrepMatches { maxBlocks = maxGrepMatches } outLines := make([]string, 0, 16+len(hits)) outLines = append(outLines, header) running := utf8.RuneCountInString(header) shown := 0 for i := 0; i < maxBlocks; i++ { block := matchBlock(hits[i], linesByPage) add := utf8.RuneCountInString(strings.Join(block, "\n")) if i == 0 { if running+add > opt.OutCap { break } } else if running+1+add > opt.OutCap { break } outLines = append(outLines, block...) running += add if i > 0 { running++ } shown++ } content := strings.Join(outLines, "\n") if utf8.RuneCountInString(content) > opt.OutCap { content = truncateRunes(content, opt.OutCap) } note := "" if shown < maxBlocks { note = fmt.Sprintf("showing %d of %d matches", shown, n) } return Rendered{Content: content, GrepHits: n, Note: note}, nil } // grepPattern applies smart case: it lowercases-sensitively prefixes "(?i)" // unless the pattern already contains an upper-case letter, which asks for a // case-sensitive search. func grepPattern(pattern string) string { if hasUpper(pattern) { return pattern } return "(?i)" + pattern } // hasUpper reports whether s contains an upper-case ASCII letter. func hasUpper(s string) bool { for _, r := range s { if r >= 'A' && r <= 'Z' { return true } } return false } // matchBlock renders one hit as its previous line, the matching line, the next // line and a separator. Context lines that do not exist (the match is on the // first or last line) are omitted. func matchBlock(h hit, linesByPage map[int][]string) []string { lines := linesByPage[h.page] var b []string if h.line > 0 { b = append(b, fmt.Sprintf("p. %d- %s", h.page, strings.TrimSpace(lines[h.line-1]))) } b = append(b, fmt.Sprintf("p. %d: %s", h.page, strings.TrimSpace(lines[h.line]))) if h.line+1 < len(lines) { b = append(b, fmt.Sprintf("p. %d- %s", h.page, strings.TrimSpace(lines[h.line+1]))) } return append(b, "--") } // splitLines splits a page into its lines, dropping the single empty line a // trailing newline would otherwise introduce. func splitLines(s string) []string { lines := strings.Split(s, "\n") if len(lines) > 1 && lines[len(lines)-1] == "" && strings.HasSuffix(s, "\n") { lines = lines[:len(lines)-1] } return lines }