v0.7.0 31c7fae

parser.go RawHistoryFolder

1package parser
2
3import (
4	"fmt"
5	neturl "net/url"
6	"regexp"
7	"strings"
8	"time"
9
10	"github.com/bnema/dumber/internal/config"
11)
12
13// ParseInput parses user input and returns a ParseResult with URL and metadata.
14// Now uses the proven CLI parsing logic for consistent behavior.
15func (p *Parser) ParseInput(input string) (*ParseResult, error) {
16	startTime := time.Now()
17
18	if input == "" {
19		return &ParseResult{
20			Type:           InputTypeFallbackSearch,
21			URL:            "https://www.google.com/search?q=",
22			Query:          input,
23			Confidence:     0.0,
24			ProcessingTime: time.Since(startTime),
25		}, nil
26	}
27
28	// Use the working CLI parsing logic
29	finalURL, err := p.parseInputUsingCLILogic(input)
30	if err != nil {
31		// If parsing fails, fall back to search
32		finalURL = fmt.Sprintf("https://www.google.com/search?q=%s", neturl.QueryEscape(input))
33	}
34
35	// Determine result type based on the URL
36	var resultType InputType = InputTypeFallbackSearch
37	confidence := 0.1
38
39	if strings.Contains(finalURL, "google.com/search") {
40		resultType = InputTypeFallbackSearch
41		confidence = 0.1
42	} else if strings.HasPrefix(finalURL, "http://") || strings.HasPrefix(finalURL, "https://") {
43		resultType = InputTypeDirectURL
44		confidence = 1.0
45	}
46
47	// Check if it was a shortcut
48	if idx := strings.Index(input, ":"); idx > 0 && idx < 10 && !strings.Contains(input, "://") {
49		shortcut := strings.TrimSpace(input[:idx])
50		if _, exists := p.config.SearchShortcuts[shortcut]; exists {
51			resultType = InputTypeSearchShortcut
52			confidence = 0.95
53		}
54	}
55
56	return &ParseResult{
57		Type:           resultType,
58		URL:            finalURL,
59		Query:          input,
60		Confidence:     confidence,
61		ProcessingTime: time.Since(startTime),
62	}, nil
63}
64
65// parseDirectURL handles direct URL inputs using CLI logic.
66func (p *Parser) parseDirectURL(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {
67	// Use the working CLI parsing logic instead
68	finalURL, err := p.parseInputUsingCLILogic(cleanInput)
69	if err != nil {
70		return nil, err
71	}
72
73	return &ParseResult{
74		Type:           InputTypeDirectURL,
75		URL:            finalURL,
76		Query:          originalInput,
77		Confidence:     1.0,
78		ProcessingTime: time.Since(startTime),
79	}, nil
80}
81
82// parseInputUsingCLILogic implements the working CLI parsing logic
83func (p *Parser) parseInputUsingCLILogic(input string) (string, error) {
84	input = strings.TrimSpace(input)
85
86	// 1. Check if it's a shortcut (format: "prefix:query")
87	if idx := strings.Index(input, ":"); idx > 0 && idx < 10 && !strings.Contains(input, "://") {
88		shortcut := strings.TrimSpace(input[:idx])
89		query := strings.TrimSpace(input[idx+1:])
90
91		if query != "" {
92			// First check configuration-based shortcuts
93			if shortcutCfg, exists := p.config.SearchShortcuts[shortcut]; exists {
94				return fmt.Sprintf(shortcutCfg.URL, neturl.QueryEscape(query)), nil
95			}
96
97			return "", fmt.Errorf("unknown shortcut '%s'", shortcut)
98		}
99	}
100
101	// 2. Already a full URL with protocol
102	if regexp.MustCompile(`^https?://`).MatchString(input) {
103		return input, nil
104	}
105
106	// 3. File protocol
107	if strings.HasPrefix(input, "file://") {
108		return input, nil
109	}
110
111	// 4. Localhost/development URLs
112	if regexp.MustCompile(`^(localhost|127\.0\.0\.1|0\.0\.0\.0|::1)(:\d+)?(/.*)?$`).MatchString(input) {
113		return "http://" + input, nil
114	}
115
116	// 5. Local network IPs
117	if regexp.MustCompile(`^(192\.168|10\.|172\.(1[6-9]|2[0-9]|3[01]))\.\d+\.\d+(:\d+)?(/.*)?$`).MatchString(input) {
118		return "http://" + input, nil
119	}
120
121	// 6. Domain with TLD (must have dot and valid TLD)
122	// More comprehensive TLD pattern for better URL detection
123	domainPattern := regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(/.*)?$`)
124	if domainPattern.MatchString(input) {
125		// Additional check for common TLDs to reduce false positives
126		commonTLDs := regexp.MustCompile(`\.(com|org|net|edu|gov|io|co|uk|de|fr|jp|cn|in|br|au|ca|ru|nl|it|es|se|no|fi|dk|pl|ch|at|be|cz|gr|il|mx|nz|sg|kr|tw|hk|my|th|vn|id|ph|za|eg|ng|ke|dev|app|xyz|tech|site|online|store|blog|info|biz|name|pro)(/.*)?$`)
127		if commonTLDs.MatchString(input) {
128			return "https://" + input, nil
129		}
130
131		// If it has a dot but uncommon TLD, still try as URL
132		return "https://" + input, nil
133	}
134
135	// 7. Possible local hostname (single word, might be on local network)
136	singleHostPattern := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$`)
137	if singleHostPattern.MatchString(input) && len(input) <= 63 {
138		// Could be local hostname, try as URL but don't validate
139		return "http://" + input, nil
140	}
141
142	// 8. Everything else is a search query
143	// This includes: multi-word phrases, questions, special characters, etc.
144	return fmt.Sprintf("https://www.google.com/search?q=%s", neturl.QueryEscape(input)), nil
145}
146
147// parseSearchShortcut handles search shortcut inputs.
148func (p *Parser) parseSearchShortcut(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {
149	validator := NewURLValidator()
150	isShortcut, shortcutKey, query := validator.IsSearchShortcut(cleanInput)
151
152	if !isShortcut {
153		return nil, fmt.Errorf("input is not a valid search shortcut")
154	}
155
156	// Look up the shortcut in configuration
157	shortcuts := p.config.SearchShortcuts
158	shortcutConfig, exists := shortcuts[shortcutKey]
159	if !exists {
160		// Unknown shortcut, treat as search query
161		return p.parseHistorySearch(cleanInput, originalInput, startTime)
162	}
163
164	// Build URL from shortcut template
165	finalURL, err := p.processShortcut(shortcutKey, query, shortcutConfig)
166	if err != nil {
167		return nil, fmt.Errorf("failed to process shortcut: %w", err)
168	}
169
170	return &ParseResult{
171		Type:       InputTypeSearchShortcut,
172		URL:        finalURL,
173		Query:      originalInput,
174		Confidence: 0.95, // High confidence for known shortcuts
175		Shortcut: &DetectedShortcut{
176			Key:         shortcutKey,
177			Query:       query,
178			URL:         finalURL,
179			Description: shortcutConfig.Description,
180		},
181		ProcessingTime: time.Since(startTime),
182	}, nil
183}
184
185// parseHistorySearch handles history search with fuzzy finding.
186func (p *Parser) parseHistorySearch(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {
187	// Get history entries for fuzzy search
188	history, err := p.historyProvider.GetAllHistory()
189	if err != nil {
190		// If history search fails, fall back to web search
191		return p.parseFallbackSearch(cleanInput, originalInput, startTime)
192	}
193
194	// Perform fuzzy search
195	fuzzyMatcher := NewFuzzyMatcher(p.fuzzyConfig)
196	matches := fuzzyMatcher.SearchHistory(cleanInput, history)
197
198	// Check if we have good matches
199	if len(matches) > 0 && matches[0].Score >= p.fuzzyConfig.MinSimilarityThreshold {
200		// Use the best match
201		bestMatch := matches[0]
202
203		return &ParseResult{
204			Type:           InputTypeHistorySearch,
205			URL:            bestMatch.HistoryEntry.Url,
206			Query:          originalInput,
207			Confidence:     bestMatch.Score,
208			FuzzyMatches:   matches,
209			ProcessingTime: time.Since(startTime),
210		}, nil
211	}
212
213	// No good history matches, fall back to search
214	fallbackResult, err := p.parseFallbackSearch(cleanInput, originalInput, startTime)
215	if err != nil {
216		return nil, err
217	}
218
219	// Include the fuzzy matches even though we're falling back
220	fallbackResult.FuzzyMatches = matches
221
222	return fallbackResult, nil
223}
224
225// parseFallbackSearch handles fallback web search.
226func (p *Parser) parseFallbackSearch(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {
227	// Use default search shortcut (usually "g" for Google)
228	searchURL := p.buildSearchURL("", cleanInput)
229
230	return &ParseResult{
231		Type:           InputTypeFallbackSearch,
232		URL:            searchURL,
233		Query:          originalInput,
234		Confidence:     0.1, // Low confidence for fallback
235		ProcessingTime: time.Since(startTime),
236	}, nil
237}
238
239// processShortcut processes a search shortcut and builds the final URL.
240func (p *Parser) processShortcut(shortcutKey, query string, shortcut config.SearchShortcut) (string, error) {
241	urlTemplate := shortcut.URL
242
243	// Replace placeholder with query
244	q := neturl.QueryEscape(query)
245	finalURL := strings.ReplaceAll(urlTemplate, "{query}", q)
246	finalURL = strings.ReplaceAll(finalURL, "%s", q)
247
248	// Validate the resulting URL
249	validator := NewURLValidator()
250	if !validator.IsValidURL(finalURL) && !strings.HasPrefix(finalURL, "http") {
251		return "", fmt.Errorf("invalid URL generated from shortcut '%s' template: %s", shortcutKey, finalURL)
252	}
253
254	return finalURL, nil
255}
256
257// buildSearchURL builds a search URL using the default search engine.
258func (p *Parser) buildSearchURL(searchEngine, query string) string {
259	q := neturl.QueryEscape(query)
260	// If no specific search engine, use Google as default
261	if searchEngine == "" {
262		defaultShortcut, exists := p.config.SearchShortcuts["g"]
263		if exists {
264			url := strings.ReplaceAll(defaultShortcut.URL, "{query}", q)
265			url = strings.ReplaceAll(url, "%s", q)
266			return url
267		}
268		// Fallback to Google if no "g" shortcut configured
269		return fmt.Sprintf("https://www.google.com/search?q=%s", q)
270	}
271
272	// Use specified search engine
273	shortcut, exists := p.config.SearchShortcuts[searchEngine]
274	if !exists {
275		return fmt.Sprintf("https://www.google.com/search?q=%s", q)
276	}
277
278	u := strings.ReplaceAll(shortcut.URL, "{query}", q)
279	u = strings.ReplaceAll(u, "%s", q)
280	return u
281}
282
283// FuzzySearchHistory performs fuzzy search on history and returns ranked matches.
284func (p *Parser) FuzzySearchHistory(query string, threshold float64) ([]FuzzyMatch, error) {
285	if query == "" {
286		return nil, nil
287	}
288
289	// Get all history entries
290	history, err := p.historyProvider.GetAllHistory()
291	if err != nil {
292		return nil, fmt.Errorf("failed to retrieve history: %w", err)
293	}
294
295	// Create fuzzy config with custom threshold if provided
296	fuzzyConfig := *p.fuzzyConfig
297	if threshold > 0 && threshold <= 1.0 {
298		fuzzyConfig.MinSimilarityThreshold = threshold
299	}
300
301	// Perform fuzzy search
302	fuzzyMatcher := NewFuzzyMatcher(&fuzzyConfig)
303	matches := fuzzyMatcher.SearchHistory(query, history)
304
305	// Apply additional ranking
306	matches = fuzzyMatcher.RankMatches(matches, query)
307
308	return matches, nil
309}
310
311// CalculateSimilarity calculates similarity between two strings.
312func (p *Parser) CalculateSimilarity(s1, s2 string) float64 {
313	fuzzyMatcher := NewFuzzyMatcher(p.fuzzyConfig)
314	return fuzzyMatcher.calculateSimilarity(s1, s2)
315}
316
317// RankMatches re-ranks fuzzy matches based on additional criteria.
318func (p *Parser) RankMatches(matches []FuzzyMatch, query string) []FuzzyMatch {
319	fuzzyMatcher := NewFuzzyMatcher(p.fuzzyConfig)
320	return fuzzyMatcher.RankMatches(matches, query)
321}
322
323// IsValidURL checks if the input represents a valid URL.
324func (p *Parser) IsValidURL(input string) bool {
325	validator := NewURLValidator()
326	return validator.IsValidURL(input)
327}
328
329// ProcessShortcut processes a shortcut and returns the resulting URL.
330func (p *Parser) ProcessShortcut(shortcut, query string, shortcuts map[string]config.SearchShortcut) (string, error) {
331	shortcutConfig, exists := shortcuts[shortcut]
332	if !exists {
333		return "", fmt.Errorf("shortcut '%s' not found", shortcut)
334	}
335
336	return p.processShortcut(shortcut, query, shortcutConfig)
337}
338
339// GetSupportedShortcuts returns a list of configured search shortcuts.
340func (p *Parser) GetSupportedShortcuts() map[string]config.SearchShortcut {
341	return p.config.SearchShortcuts
342}
343
344// UpdateFuzzyConfig updates the fuzzy matching configuration.
345func (p *Parser) UpdateFuzzyConfig(newConfig *FuzzyConfig) error {
346	if !newConfig.IsValid() {
347		return fmt.Errorf("invalid fuzzy configuration")
348	}
349
350	p.fuzzyConfig = newConfig
351	return nil
352}
353
354// GetFuzzyConfig returns the current fuzzy matching configuration.
355func (p *Parser) GetFuzzyConfig() *FuzzyConfig {
356	return p.fuzzyConfig
357}
358
359// PreviewParse provides a preview of what ParseInput would return without side effects.
360func (p *Parser) PreviewParse(input string) (*ParseResult, error) {
361	// This is essentially the same as ParseInput but could be extended
362	// to avoid any side effects if needed in the future
363	return p.ParseInput(input)
364}
365
366// SuggestCompletions provides URL/search completions based on input.
367func (p *Parser) SuggestCompletions(input string, limit int) ([]string, error) {
368	if input == "" || limit <= 0 {
369		return nil, nil
370	}
371
372	// Get fuzzy matches from history
373	matches, err := p.FuzzySearchHistory(input, 0.2) // Lower threshold for suggestions
374	if err != nil {
375		return nil, err
376	}
377
378	completions := make([]string, 0, limit)
379	seen := make(map[string]bool)
380
381	// Add URLs from fuzzy matches
382	for _, match := range matches {
383		if len(completions) >= limit {
384			break
385		}
386
387		url := match.HistoryEntry.Url
388		if !seen[url] {
389			completions = append(completions, url)
390			seen[url] = true
391		}
392	}
393
394	// Add search shortcuts if input looks like it might be a shortcut
395	if strings.Contains(input, ":") || len(input) <= 3 {
396		for shortcut, config := range p.config.SearchShortcuts {
397			if len(completions) >= limit {
398				break
399			}
400
401			if strings.HasPrefix(shortcut, strings.ToLower(input)) {
402				suggestion := fmt.Sprintf("%s: %s", shortcut, config.Description)
403				if !seen[suggestion] {
404					completions = append(completions, suggestion)
405					seen[suggestion] = true
406				}
407			}
408		}
409	}
410
411	return completions, nil
412}
413
414// ValidateConfig validates the parser configuration.
415func (p *Parser) ValidateConfig() error {
416	if p.config == nil {
417		return fmt.Errorf("parser config is nil")
418	}
419
420	if p.fuzzyConfig == nil {
421		return fmt.Errorf("fuzzy config is nil")
422	}
423
424	if !p.fuzzyConfig.IsValid() {
425		return fmt.Errorf("invalid fuzzy config")
426	}
427
428	if p.historyProvider == nil {
429		return fmt.Errorf("history provider is nil")
430	}
431
432	// Validate search shortcuts
433	if len(p.config.SearchShortcuts) == 0 {
434		return fmt.Errorf("no search shortcuts configured")
435	}
436
437	// Check for default search shortcut
438	if _, hasGoogle := p.config.SearchShortcuts["g"]; !hasGoogle {
439		// Warning: no default search configured
440	}
441
442	return nil
443}

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