feat: ctrl f to search ( new files not indexed )
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
Simple exact string match engine.
|
||||
*/
|
||||
package search
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
const defaultContextLen = 40
|
||||
|
||||
// SearchResult represents a single search match
|
||||
type SearchResult struct {
|
||||
Path string
|
||||
RelativePath string
|
||||
FileName string
|
||||
Snippet string
|
||||
Score int
|
||||
IsFolder bool
|
||||
}
|
||||
|
||||
type SearchEngine struct {
|
||||
files []fileEntry
|
||||
isIndexing bool
|
||||
}
|
||||
|
||||
// internal struct used while indexing
|
||||
type fileEntry struct {
|
||||
path string
|
||||
relativePath string
|
||||
fileName string
|
||||
content string
|
||||
isFolder bool
|
||||
}
|
||||
|
||||
func NewSearchEngine() *SearchEngine {
|
||||
return &SearchEngine{
|
||||
files: make([]fileEntry, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *SearchEngine) IsIndexing() bool {
|
||||
return e.isIndexing
|
||||
}
|
||||
|
||||
// tea cmd for indexing
|
||||
func StartIndexing(engine *SearchEngine, rootPath string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
engine.isIndexing = true
|
||||
engine.files = make([]fileEntry, 0)
|
||||
|
||||
// file walker
|
||||
filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil // skip errors
|
||||
}
|
||||
|
||||
// Skip hidden files/folders
|
||||
if strings.HasPrefix(info.Name(), ".") {
|
||||
if info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip root path itself
|
||||
if path == rootPath {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compute relative path from root
|
||||
relPath, _ := filepath.Rel(rootPath, path)
|
||||
|
||||
if info.IsDir() {
|
||||
// Index folder
|
||||
engine.files = append(engine.files, fileEntry{
|
||||
path: path,
|
||||
relativePath: relPath,
|
||||
fileName: info.Name(),
|
||||
content: "",
|
||||
isFolder: true,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// file handling
|
||||
// Only index .md files
|
||||
if !strings.HasSuffix(info.Name(), ".md") {
|
||||
return nil
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
fileName := strings.TrimSuffix(info.Name(), ".md")
|
||||
relPathDisplay := strings.TrimSuffix(relPath, ".md")
|
||||
|
||||
engine.files = append(engine.files, fileEntry{
|
||||
path: path,
|
||||
relativePath: relPathDisplay,
|
||||
fileName: fileName,
|
||||
content: strings.ToLower(string(content)),
|
||||
isFolder: false,
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
engine.isIndexing = false
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e *SearchEngine) Search(query string) []SearchResult {
|
||||
if query == "" || e.isIndexing {
|
||||
return nil
|
||||
}
|
||||
|
||||
// to make case insensitive, we don't do it while reading so that
|
||||
// display is case correct
|
||||
queryLower := strings.ToLower(query)
|
||||
results := make([]SearchResult, 0)
|
||||
|
||||
for _, file := range e.files {
|
||||
fileNameLower := strings.ToLower(file.fileName)
|
||||
contentLower := strings.ToLower(file.content)
|
||||
|
||||
// Check title matches, covers fodler name as well
|
||||
matchPos := strings.Index(fileNameLower, queryLower)
|
||||
if matchPos >= 0 {
|
||||
score := 100 // base title match score
|
||||
if fileNameLower == queryLower {
|
||||
score += 100 // exact match bonus
|
||||
} else if isWordMatch(queryLower, fileNameLower, matchPos) {
|
||||
score += 50 // word boundary bonus
|
||||
}
|
||||
results = append(results, SearchResult{
|
||||
Path: file.path,
|
||||
RelativePath: file.relativePath,
|
||||
FileName: file.fileName,
|
||||
Snippet: "",
|
||||
Score: score,
|
||||
IsFolder: file.isFolder,
|
||||
})
|
||||
continue // else double-count
|
||||
}
|
||||
|
||||
// Check content matches on files
|
||||
if !file.isFolder {
|
||||
matchPos := strings.Index(contentLower, queryLower)
|
||||
if matchPos >= 0 {
|
||||
score := 10 // base content match score
|
||||
if isWordMatch(queryLower, contentLower, matchPos) {
|
||||
score += 20 // word boundary bonus
|
||||
}
|
||||
snippet := extractSnippet(file.content, matchPos, defaultContextLen) // todo: form window width
|
||||
results = append(results, SearchResult{
|
||||
Path: file.path,
|
||||
RelativePath: file.relativePath,
|
||||
FileName: file.fileName,
|
||||
Snippet: snippet,
|
||||
Score: score,
|
||||
IsFolder: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slices.SortFunc(results, func(a, b SearchResult) int {
|
||||
return b.Score - a.Score
|
||||
})
|
||||
return results
|
||||
}
|
||||
|
||||
// isWordMatch checks if query appears as a complete word in target
|
||||
func isWordMatch(query, target string, indexInTarget int) bool {
|
||||
// Go language note: these ^ strings are not getting copied again like C++, they are just references
|
||||
isSpace := func(pos int) bool {
|
||||
return pos < 0 || pos >= len(target) || target[pos] == ' ' || target[pos] == '\n'
|
||||
}
|
||||
|
||||
return isSpace(indexInTarget-1) && isSpace(indexInTarget+len(query))
|
||||
}
|
||||
|
||||
// extractSnippet extracts context around a match position
|
||||
func extractSnippet(content string, pos, contextLen int) string {
|
||||
start := pos - contextLen
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := pos + contextLen
|
||||
if end > len(content) {
|
||||
end = len(content)
|
||||
}
|
||||
|
||||
// Adjust to word boundaries
|
||||
for start > 0 && content[start] != ' ' && content[start] != '\n' {
|
||||
start--
|
||||
}
|
||||
for end < len(content) && content[end] != ' ' && content[end] != '\n' {
|
||||
end++
|
||||
}
|
||||
|
||||
snippet := strings.TrimSpace(content[start:end])
|
||||
snippet = strings.ReplaceAll(snippet, "\n", " ")
|
||||
|
||||
// Add ellipsis
|
||||
prefix := ""
|
||||
suffix := ""
|
||||
if start > 0 {
|
||||
prefix = "..."
|
||||
}
|
||||
if end < len(content) {
|
||||
suffix = "..."
|
||||
}
|
||||
|
||||
return prefix + snippet + suffix
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// THIS FILE IS AYE EYE GENERATED
|
||||
package search
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsWordMatch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
target string
|
||||
query string
|
||||
want bool
|
||||
}{
|
||||
{"exact word", "hello world", "hello", true},
|
||||
{"word in middle", "the hello world", "hello", true},
|
||||
{"word at end", "say hello", "hello", true},
|
||||
{"partial match", "helloworld", "hello", false},
|
||||
{"suffix match", "worldhello", "hello", false},
|
||||
{"underscore is word char", "my_hello_world", "hello", false}, // underscore joins words
|
||||
{"no match", "goodbye", "hello", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
idx := strings.Index(tt.target, tt.query)
|
||||
if idx == -1 {
|
||||
if tt.want {
|
||||
t.Errorf("isWordMatch(%q, %q) expected true but query not found", tt.target, tt.query)
|
||||
}
|
||||
return
|
||||
}
|
||||
got := isWordMatch(tt.query, tt.target, idx)
|
||||
if got != tt.want {
|
||||
t.Errorf("isWordMatch(%q, %q, %d) = %v, want %v", tt.query, tt.target, idx, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchEngine(t *testing.T) {
|
||||
// Create temp directory with test files
|
||||
tmpDir, err := os.MkdirTemp("", "search_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
engine := NewSearchEngine()
|
||||
|
||||
// Manually add files for testing
|
||||
engine.files = append(engine.files, fileEntry{
|
||||
path: filepath.Join(tmpDir, "folder1", "notes.md"),
|
||||
relativePath: "folder1/notes",
|
||||
fileName: "notes",
|
||||
content: "Hello world test content",
|
||||
})
|
||||
engine.files = append(engine.files, fileEntry{
|
||||
path: filepath.Join(tmpDir, "folder1", "ideas.md"),
|
||||
relativePath: "folder1/ideas",
|
||||
fileName: "ideas",
|
||||
content: "Some ideas here",
|
||||
})
|
||||
engine.files = append(engine.files, fileEntry{
|
||||
path: filepath.Join(tmpDir, "readme.md"),
|
||||
relativePath: "readme",
|
||||
fileName: "readme",
|
||||
content: "This is the readme",
|
||||
})
|
||||
|
||||
t.Run("search by title exact", func(t *testing.T) {
|
||||
results := engine.Search("notes")
|
||||
if len(results) == 0 {
|
||||
t.Error("expected at least one result for 'notes'")
|
||||
}
|
||||
if len(results) > 0 && results[0].FileName != "notes" {
|
||||
t.Errorf("expected first result to be 'notes', got '%s'", results[0].FileName)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search by title partial", func(t *testing.T) {
|
||||
results := engine.Search("not")
|
||||
if len(results) == 0 {
|
||||
t.Error("expected at least one result for 'not'")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search by content", func(t *testing.T) {
|
||||
results := engine.Search("hello")
|
||||
if len(results) == 0 {
|
||||
t.Error("expected at least one result for 'hello'")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("word match scores higher", func(t *testing.T) {
|
||||
// "world" is a word in content
|
||||
results := engine.Search("world")
|
||||
if len(results) == 0 {
|
||||
t.Error("expected at least one result")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no results", func(t *testing.T) {
|
||||
results := engine.Search("xyznonexistent")
|
||||
if len(results) != 0 {
|
||||
t.Errorf("expected no results, got %d", len(results))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty query", func(t *testing.T) {
|
||||
results := engine.Search("")
|
||||
if results != nil {
|
||||
t.Errorf("expected nil for empty query, got %v", results)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractSnippet(t *testing.T) {
|
||||
content := "This is the start of a long piece of content that has a match somewhere in the middle."
|
||||
|
||||
snippet := extractSnippet(content, 40, 20)
|
||||
if snippet == "" {
|
||||
t.Error("expected non-empty snippet")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user