v0.10.0 0f4e9eb

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
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}

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