package main import ( "html/template" "io/fs" "log" "net/http" "path/filepath" "sort" "strings" ) var searchTmpl = template.Must(template.New("search.html").ParseFS(assets, "assets/search.html")) // Match ranks. Lower is better. const ( rankExact = 0 rankPrefix = 1 rankSubstring = 2 rankFuzzy = 3 ) type searchResult struct { Name string URL string Path string Rank int } type searchPageData struct { Query string Results []searchResult } // handleSearch walks the wiki root and renders a search results page for the // query in r.URL.Query().Get("q"). Only invoked when path is "/" and "q" is // present. func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) { query := strings.TrimSpace(r.URL.Query().Get("q")) results := searchFolders(h.root, query) w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := searchTmpl.Execute(w, searchPageData{Query: query, Results: results}); err != nil { log.Printf("search template error: %v", err) } } // searchFolders walks root and returns directories whose final path segment // matches the query, ranked best-first. Returns nil for an empty query. func searchFolders(root, query string) []searchResult { if query == "" { return nil } q := strings.ToLower(query) maxDist := 2 if len([]rune(q)) > 6 { maxDist = 3 } var results []searchResult _ = filepath.WalkDir(root, func(fsPath string, d fs.DirEntry, err error) error { if err != nil { return nil } name := d.Name() if strings.HasPrefix(name, ".") { if d.IsDir() && fsPath != root { return filepath.SkipDir } return nil } if !d.IsDir() || fsPath == root { return nil } rank, ok := matchRank(strings.ToLower(name), q, maxDist) if !ok { return nil } rel, relErr := filepath.Rel(root, fsPath) if relErr != nil { return nil } urlPath := "/" + filepath.ToSlash(rel) + "/" results = append(results, searchResult{ Name: name, URL: urlPath, Path: filepath.ToSlash(rel), Rank: rank, }) return nil }) sort.SliceStable(results, func(i, j int) bool { if results[i].Rank != results[j].Rank { return results[i].Rank < results[j].Rank } return strings.ToLower(results[i].Name) < strings.ToLower(results[j].Name) }) return results } // matchRank returns the best (lowest) rank for which name matches q, or // (0, false) if no rule matches. Inputs are expected to be lowercased. func matchRank(name, q string, maxDist int) (int, bool) { if name == q { return rankExact, true } if strings.HasPrefix(name, q) { return rankPrefix, true } if strings.Contains(name, q) { return rankSubstring, true } if levenshtein(name, q) <= maxDist { return rankFuzzy, true } return 0, false } // levenshtein returns the edit distance between a and b. Operates on runes so // multi-byte characters count as one edit. func levenshtein(a, b string) int { ar, br := []rune(a), []rune(b) if len(ar) == 0 { return len(br) } if len(br) == 0 { return len(ar) } prev := make([]int, len(br)+1) curr := make([]int, len(br)+1) for j := range prev { prev[j] = j } for i := 1; i <= len(ar); i++ { curr[0] = i for j := 1; j <= len(br); j++ { cost := 1 if ar[i-1] == br[j-1] { cost = 0 } del := prev[j] + 1 ins := curr[j-1] + 1 sub := prev[j-1] + cost curr[j] = min3(del, ins, sub) } prev, curr = curr, prev } return prev[len(br)] } func min3(a, b, c int) int { m := a if b < m { m = b } if c < m { m = c } return m }