nandi/oripublic Fork 0
34e69b510306161654f26903a269247aefa9c94d
Commits
Clone
git clone https://git.rickub.com/nandi/ori.git
git clone ssh://git@rickub.com/nandi/ori.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

forked from bots-garden/ori

search.go · 144 lines · 4.1 KBGo Blame HistoryRaw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
package files

import (
	"io/fs"
	"net/http"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
)

// Search limits: the endpoint feeds an autocomplete popup, so it favours a
// fast, bounded answer over exhaustiveness on huge trees.
const (
	defaultSearchLimit = 50
	maxSearchLimit     = 500
	// maxSearchVisited bounds the number of directory entries walked per
	// request, so a giant workspace cannot stall the server.
	maxSearchVisited = 50000
)

// skippedDirectories are never descended into: their contents are noise for a
// file mention (and node_modules alone can hold hundreds of thousands of files).
var skippedDirectories = map[string]bool{
	".git":         true,
	"node_modules": true,
}

// Hit is one file matched by the search endpoint.
type Hit struct {
	// Name is the base name of the file.
	Name string `json:"name"`
	// Path is the absolute path, directly usable in further API calls.
	Path string `json:"path"`
	// RelPath is the path relative to the workspace root, using forward
	// slashes: the form a mention such as "@src/main.go" displays.
	RelPath string `json:"relPath"`
}

// Search walks the workspace root recursively and returns the files whose
// relative path contains query (case-insensitive), at most limit of them.
// An empty query returns the first files in walk order. Results are ranked:
// base-name matches first, then shorter paths.
//
// Example:
//
//	hits := files.New("/work").Search("main", 20)
//	// [{Name:"main.go" Path:"/work/cmd/main.go" RelPath:"cmd/main.go"}]
func (s *Service) Search(query string, limit int) []Hit {
	if limit <= 0 {
		limit = defaultSearchLimit
	}
	if limit > maxSearchLimit {
		limit = maxSearchLimit
	}
	walker := &searchWalker{root: s.root, needle: strings.ToLower(query)}
	_ = filepath.WalkDir(s.root, walker.visit)

	rankHits(walker.hits, walker.needle)
	if len(walker.hits) > limit {
		walker.hits = walker.hits[:limit]
	}
	return walker.hits
}

// searchWalker accumulates the files matching needle during one WalkDir.
type searchWalker struct {
	root    string
	needle  string
	visited int
	hits    []Hit
}

// visit is the WalkDir callback: it bounds the walk, prunes noisy
// directories and collects matching files.
func (w *searchWalker) visit(path string, d fs.DirEntry, err error) error {
	if err != nil {
		return nil // unreadable entries are skipped, not fatal
	}
	w.visited++
	if w.visited > maxSearchVisited {
		return fs.SkipAll
	}
	if d.IsDir() {
		return w.visitDir(path, d)
	}
	w.collect(path, d)
	return nil
}

// visitDir skips the directories whose contents are noise for a mention.
func (w *searchWalker) visitDir(path string, d fs.DirEntry) error {
	if path != w.root && skippedDirectories[d.Name()] {
		return fs.SkipDir
	}
	return nil
}

// collect records the file when its relative path contains the needle.
func (w *searchWalker) collect(path string, d fs.DirEntry) {
	rel, err := filepath.Rel(w.root, path)
	if err != nil {
		return
	}
	rel = filepath.ToSlash(rel)
	if w.needle != "" && !strings.Contains(strings.ToLower(rel), w.needle) {
		return
	}
	w.hits = append(w.hits, Hit{Name: d.Name(), Path: path, RelPath: rel})
}

// rankHits orders matches by relevance: files whose base name contains the
// query before those matched only through a directory name, then shorter
// paths first, then alphabetically for a stable order.
func rankHits(hits []Hit, needle string) {
	nameMatches := func(h Hit) bool {
		return needle == "" || strings.Contains(strings.ToLower(h.Name), needle)
	}
	sort.SliceStable(hits, func(i, j int) bool {
		ni, nj := nameMatches(hits[i]), nameMatches(hits[j])
		if ni != nj {
			return ni
		}
		if len(hits[i].RelPath) != len(hits[j].RelPath) {
			return len(hits[i].RelPath) < len(hits[j].RelPath)
		}
		return hits[i].RelPath < hits[j].RelPath
	})
}

func (s *Service) searchFiles(w http.ResponseWriter, r *http.Request) {
	query := r.URL.Query()
	limit, _ := strconv.Atoi(query.Get("limit"))
	if _, err := os.Stat(s.root); err != nil {
		writeFSError(w, err)
		return
	}
	hits := s.Search(query.Get("q"), limit)
	if hits == nil {
		hits = []Hit{}
	}
	writeJSON(w, http.StatusOK, map[string]any{"root": s.root, "files": hits})
}