1// Package autocomplete provides domain types and logic for URL autocompletion.
2package autocomplete
3
4import (
5 stdurl "net/url"
6 "strings"
7 "unicode/utf8"
8)
9
10// SuggestionSource indicates the origin of an autocomplete suggestion.
11type SuggestionSource int
12
13const (
14 SourceHistory SuggestionSource = iota
15 SourceFavorite
16 SourceBangShortcut
17)
18
19// Suggestion represents a single autocomplete suggestion with its completion suffix.
20type Suggestion struct {
21 FullText string // The complete text (URL or bang shortcut)
22 Suffix string // The suffix to append after the user's input
23 Source SuggestionSource // Where this suggestion came from
24 Title string // Display title (page title or description)
25}
26
27// ComputeCompletionSuffix returns the suffix if input is a case-insensitive prefix of fullText.
28// Returns the suffix and true if input matches as a prefix, otherwise empty string and false.
29func ComputeCompletionSuffix(input, fullText string) (string, bool) {
30 if input == "" || fullText == "" {
31 return "", false
32 }
33
34 inputLower := strings.ToLower(input)
35 fullLower := strings.ToLower(fullText)
36
37 if !strings.HasPrefix(fullLower, inputLower) {
38 return "", false
39 }
40
41 // Return the original-case suffix from fullText
42 suffix := fullText[len(input):]
43 return suffix, suffix != ""
44}
45
46// StripProtocol removes http:// or https:// prefix from a URL for matching.
47func StripProtocol(url string) string {
48 if strings.HasPrefix(url, "https://") {
49 return url[8:]
50 }
51 if strings.HasPrefix(url, "http://") {
52 return url[7:]
53 }
54 return url
55}
56
57// ComputeURLCompletionSuffix computes the completion suffix for URLs,
58// handling protocol stripping for better matching.
59// It tries matching with and without protocol prefixes.
60func ComputeURLCompletionSuffix(input, fullURL string) (suffix, matchedURL string, ok bool) {
61 // First try direct match
62 if suffix, ok := ComputeCompletionSuffix(input, fullURL); ok {
63 return suffix, fullURL, true
64 }
65
66 // Try matching against URL without protocol
67 strippedURL := StripProtocol(fullURL)
68 if suffix, ok := ComputeCompletionSuffix(input, strippedURL); ok {
69 return suffix, strippedURL, true
70 }
71
72 // Try matching if input has www. but URL doesn't (or vice versa)
73 inputNoWWW := strings.TrimPrefix(input, "www.")
74 strippedNoWWW := strings.TrimPrefix(strippedURL, "www.")
75 if suffix, ok := ComputeCompletionSuffix(inputNoWWW, strippedNoWWW); ok {
76 // Return suffix relative to input
77 if strings.HasPrefix(strings.ToLower(strippedURL), strings.ToLower(input)) {
78 return strippedURL[len(input):], strippedURL, true
79 }
80 return suffix, strippedNoWWW, true
81 }
82
83 return "", "", false
84}
85
86// BestURLCompletion returns the best completion candidate from a list of URLs.
87// For host-like queries (for example "goo"), it prefers short host completions
88// over deep redirect paths.
89func BestURLCompletion(input string, urls []string) (suffix, matchedURL string, ok bool) {
90 suffix, matchedURL, _, ok = BestURLCompletionWithIndex(input, urls)
91 return suffix, matchedURL, ok
92}
93
94// BestURLCompletionWithIndex returns the best completion candidate and its source URL index.
95func BestURLCompletionWithIndex(input string, urls []string) (suffix, matchedURL string, index int, ok bool) {
96 bestScore := completionScore{
97 valid: false,
98 }
99 bestSuffix := ""
100 bestMatched := ""
101 bestIndex := -1
102
103 for idx, rawURL := range urls {
104 if rawURL == "" {
105 continue
106 }
107
108 candidateText, candidateSuffix, candidateOK := completionCandidateForURL(input, rawURL)
109 if !candidateOK {
110 continue
111 }
112
113 score := rankCompletion(candidateText, candidateSuffix, idx)
114 if !bestScore.valid || score.less(bestScore) {
115 bestScore = score
116 bestSuffix = candidateSuffix
117 bestMatched = candidateText
118 bestIndex = idx
119 }
120 }
121
122 if !bestScore.valid {
123 return "", "", -1, false
124 }
125 return bestSuffix, bestMatched, bestIndex, true
126}
127
128func completionCandidateForURL(input, rawURL string) (matchedURL, suffix string, ok bool) {
129 if looksLikeHostInput(input) {
130 if host := hostForCompletion(rawURL); host != "" {
131 for _, candidate := range []string{host, strings.TrimPrefix(host, "www.")} {
132 if completionSuffix, completionOK := ComputeCompletionSuffix(input, candidate); completionOK {
133 return candidate, completionSuffix, true
134 }
135 }
136 }
137 }
138
139 completionSuffix, completionMatched, completionOK := ComputeURLCompletionSuffix(input, rawURL)
140 if !completionOK {
141 return "", "", false
142 }
143 return completionMatched, completionSuffix, true
144}
145
146func looksLikeHostInput(input string) bool {
147 if input == "" || strings.HasPrefix(input, "!") {
148 return false
149 }
150 return !strings.ContainsAny(input, "/?#=& ")
151}
152
153func hostForCompletion(raw string) string {
154 if raw == "" {
155 return ""
156 }
157 if parsed, err := stdurl.Parse(raw); err == nil && parsed.Host != "" {
158 return strings.ToLower(parsed.Hostname())
159 }
160
161 trimmed := strings.ToLower(StripProtocol(raw))
162 for _, sep := range []string{"/", "?", "#"} {
163 if idx := strings.Index(trimmed, sep); idx >= 0 {
164 trimmed = trimmed[:idx]
165 }
166 }
167 return strings.TrimSpace(trimmed)
168}
169
170type completionScore struct {
171 valid bool
172 hasPathOrArgs bool
173 suffixLen int
174 totalLen int
175 index int
176}
177
178func rankCompletion(matchedURL, suffix string, index int) completionScore {
179 return completionScore{
180 valid: true,
181 hasPathOrArgs: strings.ContainsAny(matchedURL, "/?#"),
182 suffixLen: utf8.RuneCountInString(suffix),
183 totalLen: utf8.RuneCountInString(matchedURL),
184 index: index,
185 }
186}
187
188func (s completionScore) less(other completionScore) bool {
189 if s.hasPathOrArgs != other.hasPathOrArgs {
190 return !s.hasPathOrArgs
191 }
192 if s.index != other.index {
193 return s.index < other.index
194 }
195 if s.suffixLen != other.suffixLen {
196 return s.suffixLen < other.suffixLen
197 }
198 if s.totalLen != other.totalLen {
199 return s.totalLen < other.totalLen
200 }
201 return false
202}