1package parser
2
3import (
4 "net"
5 "net/url"
6 "regexp"
7 "strings"
8)
9
10var (
11 // domainRegex matches valid domain names
12 domainRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$`)
13
14 // ipRegex matches IPv4 addresses
15
16 // urlSchemeRegex matches URL schemes
17 urlSchemeRegex = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*://`)
18
19 // shortcutRegex matches search shortcuts (e.g., "g:", "gh:")
20 shortcutRegex = regexp.MustCompile(`^([a-zA-Z0-9]+):\s*(.*)$`)
21
22 // Common top-level domains for validation
23 commonTLDs = map[string]bool{
24 "com": true, "org": true, "net": true, "edu": true, "gov": true, "mil": true,
25 "int": true, "co": true, "io": true, "ai": true, "ly": true, "me": true,
26 "tv": true, "cc": true, "to": true, "app": true, "dev": true, "tech": true,
27 "info": true, "biz": true, "name": true, "museum": true, "travel": true,
28 "xyz": true, "fr": true, "de": true, "uk": true, "ca": true, "us": true,
29 "au": true, "jp": true, "cn": true, "in": true, "ru": true, "br": true,
30 }
31)
32
33// URLValidator provides URL validation utilities.
34type URLValidator struct{}
35
36// NewURLValidator creates a new URLValidator.
37func NewURLValidator() *URLValidator {
38 return &URLValidator{}
39}
40
41// IsValidURL checks if the input represents a valid URL.
42func (v *URLValidator) IsValidURL(input string) bool {
43 if input == "" {
44 return false
45 }
46
47 // Check if it already has a scheme
48 if urlSchemeRegex.MatchString(input) {
49 return v.isWellFormedURL(input)
50 }
51
52 // Check if it looks like a domain or IP
53 return v.isDomain(input) || v.isIPAddress(input)
54}
55
56// IsDirectURL checks if the input should be treated as a direct URL.
57func (v *URLValidator) IsDirectURL(input string) bool {
58 if input == "" {
59 return false
60 }
61
62 input = strings.TrimSpace(input)
63
64 // Has explicit scheme
65 if urlSchemeRegex.MatchString(input) {
66 return v.isWellFormedURL(input)
67 }
68
69 // Looks like a domain
70 if v.isDomain(input) {
71 return true
72 }
73
74 // Looks like an IP address
75 if v.isIPAddress(input) {
76 return true
77 }
78
79 // Contains domain-like patterns
80 if v.looksLikeDomain(input) {
81 return true
82 }
83
84 return false
85}
86
87// IsSearchShortcut checks if the input matches a search shortcut pattern.
88func (v *URLValidator) IsSearchShortcut(input string) (bool, string, string) {
89 input = strings.TrimSpace(input)
90
91 // Don't match URLs with schemes (like https://example.com)
92 if urlSchemeRegex.MatchString(input) {
93 return false, "", ""
94 }
95
96 matches := shortcutRegex.FindStringSubmatch(input)
97 if len(matches) != 3 {
98 return false, "", ""
99 }
100
101 shortcutKey := strings.ToLower(matches[1])
102 query := strings.TrimSpace(matches[2])
103
104 return true, shortcutKey, query
105}
106
107// NormalizeURL normalizes a URL by adding scheme if missing.
108func (v *URLValidator) NormalizeURL(input string) string {
109 input = strings.TrimSpace(input)
110
111 if input == "" {
112 return input
113 }
114
115 // Already has a scheme
116 if urlSchemeRegex.MatchString(input) {
117 return input
118 }
119
120 // Add https:// for domains and IPs
121 if v.isDomain(input) || v.isIPAddress(input) || v.looksLikeDomain(input) {
122 return "https://" + input
123 }
124
125 return input
126}
127
128// ExtractDomain extracts the domain from a URL.
129func (v *URLValidator) ExtractDomain(rawURL string) string {
130 // Parse the URL
131 parsedURL, err := url.Parse(v.NormalizeURL(rawURL))
132 if err != nil {
133 // Fallback: try to extract domain manually
134 return v.extractDomainManual(rawURL)
135 }
136
137 // Extract hostname (without port) from Host
138 host := parsedURL.Host
139 if host == "" {
140 return v.extractDomainManual(rawURL)
141 }
142
143 // Remove port from host if present
144 hostname := host
145 if idx := strings.LastIndex(host, ":"); idx != -1 {
146 // Check if this looks like a port (numeric)
147 portPart := host[idx+1:]
148 if isNumeric(portPart) {
149 hostname = host[:idx]
150 }
151 }
152
153 // Handle IPv6 addresses in brackets
154 if strings.HasPrefix(hostname, "[") && strings.HasSuffix(hostname, "]") {
155 hostname = hostname[1 : len(hostname)-1]
156 }
157
158 return hostname
159}
160
161// extractDomainManual manually extracts domain when URL parsing fails.
162func (v *URLValidator) extractDomainManual(rawURL string) string {
163 // Handle empty or invalid input
164 if rawURL == "" {
165 return ""
166 }
167
168 // Remove protocol
169 domain := rawURL
170 if strings.HasPrefix(domain, "http://") {
171 domain = domain[7:]
172 } else if strings.HasPrefix(domain, "https://") {
173 domain = domain[8:]
174 }
175
176 // Handle IPv6 addresses in brackets
177 if strings.HasPrefix(domain, "[") {
178 if idx := strings.Index(domain, "]"); idx != -1 {
179 return domain[1:idx] // Return IPv6 address without brackets
180 }
181 }
182
183 // Remove path
184 if idx := strings.Index(domain, "/"); idx != -1 {
185 domain = domain[:idx]
186 }
187
188 // Remove query and fragment
189 if idx := strings.Index(domain, "?"); idx != -1 {
190 domain = domain[:idx]
191 }
192 if idx := strings.Index(domain, "#"); idx != -1 {
193 domain = domain[:idx]
194 }
195
196 // Remove port - be careful with IPv6 addresses
197 if !strings.Contains(domain, "::") { // Not IPv6
198 if idx := strings.LastIndex(domain, ":"); idx != -1 {
199 // Check if this is actually a port (numeric)
200 portPart := domain[idx+1:]
201 if isNumeric(portPart) {
202 domain = domain[:idx]
203 }
204 }
205 }
206
207 return domain
208}
209
210// isDomain checks if the input is a valid domain name.
211func (v *URLValidator) isDomain(input string) bool {
212 if !domainRegex.MatchString(input) {
213 return false
214 }
215
216 // Check for at least one dot (to distinguish from single words)
217 if !strings.Contains(input, ".") {
218 return false
219 }
220
221 // Validate TLD
222 parts := strings.Split(input, ".")
223 if len(parts) < 2 {
224 return false
225 }
226
227 tld := strings.ToLower(parts[len(parts)-1])
228
229 // Check against common TLDs or validate length/format
230 return commonTLDs[tld] || (len(tld) >= 2 && len(tld) <= 6)
231}
232
233// isIPAddress checks if the input is a valid IP address.
234func (v *URLValidator) isIPAddress(input string) bool {
235 // Try parsing as IP
236 ip := net.ParseIP(input)
237 return ip != nil
238}
239
240// looksLikeDomain checks if the input looks like a domain (more lenient).
241func (v *URLValidator) looksLikeDomain(input string) bool {
242 // Extract just the domain part if there's a path
243 domainPart := input
244 if slashIndex := strings.Index(input, "/"); slashIndex != -1 {
245 domainPart = input[:slashIndex]
246 }
247
248 // Must contain at least one dot
249 if !strings.Contains(domainPart, ".") {
250 return false
251 }
252
253 // Must not contain spaces
254 if strings.Contains(domainPart, " ") {
255 return false
256 }
257
258 // Must not be too long
259 if len(domainPart) > 253 {
260 return false
261 }
262
263 // Check basic domain-like structure
264 parts := strings.Split(domainPart, ".")
265 if len(parts) < 2 {
266 return false
267 }
268
269 // Each part should be reasonable
270 for _, part := range parts {
271 if len(part) == 0 || len(part) > 63 {
272 return false
273 }
274 // Should start and end with alphanumeric
275 if len(part) > 0 {
276 if !isAlphaNumeric(part[0]) || !isAlphaNumeric(part[len(part)-1]) {
277 return false
278 }
279 }
280 }
281
282 return true
283}
284
285// isWellFormedURL checks if a URL with scheme is well-formed.
286func (v *URLValidator) isWellFormedURL(rawURL string) bool {
287 parsedURL, err := url.Parse(rawURL)
288 if err != nil {
289 return false
290 }
291
292 // Must have a scheme and host
293 if parsedURL.Scheme == "" || parsedURL.Host == "" {
294 return false
295 }
296
297 // Validate scheme
298 validSchemes := map[string]bool{
299 "http": true, "https": true, "ftp": true, "ftps": true,
300 "file": true, "mailto": true, "tel": true, "sms": true,
301 }
302
303 return validSchemes[strings.ToLower(parsedURL.Scheme)]
304}
305
306// HasFileExtension checks if the URL path has a file extension.
307func (v *URLValidator) HasFileExtension(rawURL string) bool {
308 parsedURL, err := url.Parse(v.NormalizeURL(rawURL))
309 if err != nil {
310 return false
311 }
312
313 path := parsedURL.Path
314 if path == "" || path == "/" {
315 return false
316 }
317
318 // Check for file extension
319 lastSlash := strings.LastIndex(path, "/")
320 fileName := path
321 if lastSlash != -1 && lastSlash < len(path)-1 {
322 fileName = path[lastSlash+1:]
323 }
324
325 return strings.Contains(fileName, ".") && !strings.HasSuffix(fileName, ".")
326}
327
328// IsLocalhost checks if the URL is pointing to localhost.
329func (v *URLValidator) IsLocalhost(rawURL string) bool {
330 // Handle direct localhost strings (no protocol)
331 rawURL = strings.ToLower(strings.TrimSpace(rawURL))
332 if rawURL == "localhost" || rawURL == "127.0.0.1" || rawURL == "::1" {
333 return true
334 }
335
336 domain := v.ExtractDomain(rawURL)
337 domain = strings.ToLower(domain)
338
339 // Remove port if present
340 if idx := strings.LastIndex(domain, ":"); idx != -1 {
341 portPart := domain[idx+1:]
342 if isNumeric(portPart) {
343 domain = domain[:idx]
344 }
345 }
346
347 return domain == "localhost" ||
348 domain == "127.0.0.1" ||
349 domain == "::1" ||
350 strings.HasSuffix(domain, ".local")
351}
352
353// SanitizeInput sanitizes user input for URL parsing.
354func (v *URLValidator) SanitizeInput(input string) string {
355 // Trim whitespace
356 input = strings.TrimSpace(input)
357
358 // Remove control characters
359 result := make([]rune, 0, len(input))
360 for _, r := range input {
361 if r >= 32 && r < 127 || r > 127 { // Keep printable ASCII and non-ASCII
362 result = append(result, r)
363 }
364 }
365
366 return string(result)
367}
368
369// Helper functions
370
371// isNumeric checks if a string contains only digits.
372func isNumeric(s string) bool {
373 if s == "" {
374 return false
375 }
376 for _, r := range s {
377 if r < '0' || r > '9' {
378 return false
379 }
380 }
381 return true
382}
383
384// isAlphaNumeric checks if a character is alphanumeric.
385func isAlphaNumeric(b byte) bool {
386 return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
387}
388
389// GetURLType determines the type of URL (direct, search, etc.).
390func (v *URLValidator) GetURLType(input string) InputType {
391 input = v.SanitizeInput(input)
392
393 // Check for direct URL first (this includes URLs with schemes)
394 if v.IsDirectURL(input) {
395 return InputTypeDirectURL
396 }
397
398 // Check for search shortcut (but exclude URL schemes)
399 if isShortcut, _, _ := v.IsSearchShortcut(input); isShortcut {
400 return InputTypeSearchShortcut
401 }
402
403 // Everything else is treated as search query initially
404 // The parser will determine if it should be history search or web search
405 return InputTypeHistorySearch
406}