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