v0.4.0 6203a22

validation.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close