v0.3.0 d226234

parser.go RawHistoryFolder

1package parser
2
3import (
4	"fmt"
5	neturl "net/url"
6	"strings"
7	"time"
8
9	"github.com/bnema/dumber/internal/config"
10)
11
12// ParseInput parses user input and returns a ParseResult with URL and metadata.
13//
14// Learning Behavior: When users search with shortcuts like "g: viper cli" and visit
15// results, those URLs are recorded in history. Future searches for "viper cli" will
16// find the GitHub page in history via fuzzy matching, creating intelligent learning.
17func (p *Parser) ParseInput(input string) (*ParseResult, error) {
18	startTime := time.Now()
19
20	// Sanitize input
21	validator := NewURLValidator()
22	cleanInput := validator.SanitizeInput(input)
23
24	if cleanInput == "" {
25		return &ParseResult{
26			Type:           InputTypeFallbackSearch,
27			URL:            p.buildSearchURL("", cleanInput),
28			Query:          input,
29			Confidence:     0.0,
30			ProcessingTime: time.Since(startTime),
31		}, nil
32	}
33
34	// Determine input type
35	inputType := validator.GetURLType(cleanInput)
36
37	switch inputType {
38	case InputTypeDirectURL:
39		return p.parseDirectURL(cleanInput, input, startTime)
40
41	case InputTypeSearchShortcut:
42		return p.parseSearchShortcut(cleanInput, input, startTime)
43
44	case InputTypeHistorySearch:
45		return p.parseHistorySearch(cleanInput, input, startTime)
46
47	default:
48		return p.parseFallbackSearch(cleanInput, input, startTime)
49	}
50}
51
52// parseDirectURL handles direct URL inputs.
53func (p *Parser) parseDirectURL(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {
54	validator := NewURLValidator()
55	normalizedURL := validator.NormalizeURL(cleanInput)
56
57	return &ParseResult{
58		Type:           InputTypeDirectURL,
59		URL:            normalizedURL,
60		Query:          originalInput,
61		Confidence:     1.0, // High confidence for direct URLs
62		ProcessingTime: time.Since(startTime),
63	}, nil
64}
65
66// parseSearchShortcut handles search shortcut inputs.
67func (p *Parser) parseSearchShortcut(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {
68	validator := NewURLValidator()
69	isShortcut, shortcutKey, query := validator.IsSearchShortcut(cleanInput)
70
71	if !isShortcut {
72		return nil, fmt.Errorf("input is not a valid search shortcut")
73	}
74
75	// Look up the shortcut in configuration
76	shortcuts := p.config.SearchShortcuts
77	shortcutConfig, exists := shortcuts[shortcutKey]
78	if !exists {
79		// Unknown shortcut, treat as search query
80		return p.parseHistorySearch(cleanInput, originalInput, startTime)
81	}
82
83	// Build URL from shortcut template
84	finalURL, err := p.processShortcut(shortcutKey, query, shortcutConfig)
85	if err != nil {
86		return nil, fmt.Errorf("failed to process shortcut: %w", err)
87	}
88
89	return &ParseResult{
90		Type:       InputTypeSearchShortcut,
91		URL:        finalURL,
92		Query:      originalInput,
93		Confidence: 0.95, // High confidence for known shortcuts
94		Shortcut: &DetectedShortcut{
95			Key:         shortcutKey,
96			Query:       query,
97			URL:         finalURL,
98			Description: shortcutConfig.Description,
99		},
100		ProcessingTime: time.Since(startTime),
101	}, nil
102}
103
104// parseHistorySearch handles history search with fuzzy finding.
105func (p *Parser) parseHistorySearch(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {
106	// Get history entries for fuzzy search
107	history, err := p.historyProvider.GetAllHistory()
108	if err != nil {
109		// If history search fails, fall back to web search
110		return p.parseFallbackSearch(cleanInput, originalInput, startTime)
111	}
112
113	// Perform fuzzy search
114	fuzzyMatcher := NewFuzzyMatcher(p.fuzzyConfig)
115	matches := fuzzyMatcher.SearchHistory(cleanInput, history)
116
117	// Check if we have good matches
118	if len(matches) > 0 && matches[0].Score >= p.fuzzyConfig.MinSimilarityThreshold {
119		// Use the best match
120		bestMatch := matches[0]
121
122		return &ParseResult{
123			Type:           InputTypeHistorySearch,
124			URL:            bestMatch.HistoryEntry.Url,
125			Query:          originalInput,
126			Confidence:     bestMatch.Score,
127			FuzzyMatches:   matches,
128			ProcessingTime: time.Since(startTime),
129		}, nil
130	}
131
132	// No good history matches, fall back to search
133	fallbackResult, err := p.parseFallbackSearch(cleanInput, originalInput, startTime)
134	if err != nil {
135		return nil, err
136	}
137
138	// Include the fuzzy matches even though we're falling back
139	fallbackResult.FuzzyMatches = matches
140
141	return fallbackResult, nil
142}
143
144// parseFallbackSearch handles fallback web search.
145func (p *Parser) parseFallbackSearch(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {
146	// Use default search shortcut (usually "g" for Google)
147	searchURL := p.buildSearchURL("", cleanInput)
148
149	return &ParseResult{
150		Type:           InputTypeFallbackSearch,
151		URL:            searchURL,
152		Query:          originalInput,
153		Confidence:     0.1, // Low confidence for fallback
154		ProcessingTime: time.Since(startTime),
155	}, nil
156}
157
158// processShortcut processes a search shortcut and builds the final URL.
159func (p *Parser) processShortcut(shortcutKey, query string, shortcut config.SearchShortcut) (string, error) {
160	urlTemplate := shortcut.URL
161
162	// Replace placeholder with query
163	q := neturl.QueryEscape(query)
164	finalURL := strings.ReplaceAll(urlTemplate, "{query}", q)
165	finalURL = strings.ReplaceAll(finalURL, "%s", q)
166
167	// Validate the resulting URL
168	validator := NewURLValidator()
169	if !validator.IsValidURL(finalURL) && !strings.HasPrefix(finalURL, "http") {
170		return "", fmt.Errorf("invalid URL generated from shortcut template: %s", finalURL)
171	}
172
173	return finalURL, nil
174}
175
176// buildSearchURL builds a search URL using the default search engine.
177func (p *Parser) buildSearchURL(searchEngine, query string) string {
178	q := neturl.QueryEscape(query)
179	// If no specific search engine, use Google as default
180	if searchEngine == "" {
181		defaultShortcut, exists := p.config.SearchShortcuts["g"]
182		if exists {
183			url := strings.ReplaceAll(defaultShortcut.URL, "{query}", q)
184			url = strings.ReplaceAll(url, "%s", q)
185			return url
186		}
187		// Fallback to Google if no "g" shortcut configured
188		return fmt.Sprintf("https://www.google.com/search?q=%s", q)
189	}
190
191	// Use specified search engine
192	shortcut, exists := p.config.SearchShortcuts[searchEngine]
193	if !exists {
194		return fmt.Sprintf("https://www.google.com/search?q=%s", q)
195	}
196
197	u := strings.ReplaceAll(shortcut.URL, "{query}", q)
198	u = strings.ReplaceAll(u, "%s", q)
199	return u
200}
201
202// FuzzySearchHistory performs fuzzy search on history and returns ranked matches.
203func (p *Parser) FuzzySearchHistory(query string, threshold float64) ([]FuzzyMatch, error) {
204	if query == "" {
205		return nil, nil
206	}
207
208	// Get all history entries
209	history, err := p.historyProvider.GetAllHistory()
210	if err != nil {
211		return nil, fmt.Errorf("failed to retrieve history: %w", err)
212	}
213
214	// Create fuzzy config with custom threshold if provided
215	fuzzyConfig := *p.fuzzyConfig
216	if threshold > 0 && threshold <= 1.0 {
217		fuzzyConfig.MinSimilarityThreshold = threshold
218	}
219
220	// Perform fuzzy search
221	fuzzyMatcher := NewFuzzyMatcher(&fuzzyConfig)
222	matches := fuzzyMatcher.SearchHistory(query, history)
223
224	// Apply additional ranking
225	matches = fuzzyMatcher.RankMatches(matches, query)
226
227	return matches, nil
228}
229
230// CalculateSimilarity calculates similarity between two strings.
231func (p *Parser) CalculateSimilarity(s1, s2 string) float64 {
232	fuzzyMatcher := NewFuzzyMatcher(p.fuzzyConfig)
233	return fuzzyMatcher.calculateSimilarity(s1, s2)
234}
235
236// RankMatches re-ranks fuzzy matches based on additional criteria.
237func (p *Parser) RankMatches(matches []FuzzyMatch, query string) []FuzzyMatch {
238	fuzzyMatcher := NewFuzzyMatcher(p.fuzzyConfig)
239	return fuzzyMatcher.RankMatches(matches, query)
240}
241
242// IsValidURL checks if the input represents a valid URL.
243func (p *Parser) IsValidURL(input string) bool {
244	validator := NewURLValidator()
245	return validator.IsValidURL(input)
246}
247
248// ProcessShortcut processes a shortcut and returns the resulting URL.
249func (p *Parser) ProcessShortcut(shortcut, query string, shortcuts map[string]config.SearchShortcut) (string, error) {
250	shortcutConfig, exists := shortcuts[shortcut]
251	if !exists {
252		return "", fmt.Errorf("shortcut '%s' not found", shortcut)
253	}
254
255	return p.processShortcut(shortcut, query, shortcutConfig)
256}
257
258// GetSupportedShortcuts returns a list of configured search shortcuts.
259func (p *Parser) GetSupportedShortcuts() map[string]config.SearchShortcut {
260	return p.config.SearchShortcuts
261}
262
263// UpdateFuzzyConfig updates the fuzzy matching configuration.
264func (p *Parser) UpdateFuzzyConfig(newConfig *FuzzyConfig) error {
265	if !newConfig.IsValid() {
266		return fmt.Errorf("invalid fuzzy configuration")
267	}
268
269	p.fuzzyConfig = newConfig
270	return nil
271}
272
273// GetFuzzyConfig returns the current fuzzy matching configuration.
274func (p *Parser) GetFuzzyConfig() *FuzzyConfig {
275	return p.fuzzyConfig
276}
277
278// PreviewParse provides a preview of what ParseInput would return without side effects.
279func (p *Parser) PreviewParse(input string) (*ParseResult, error) {
280	// This is essentially the same as ParseInput but could be extended
281	// to avoid any side effects if needed in the future
282	return p.ParseInput(input)
283}
284
285// SuggestCompletions provides URL/search completions based on input.
286func (p *Parser) SuggestCompletions(input string, limit int) ([]string, error) {
287	if input == "" || limit <= 0 {
288		return nil, nil
289	}
290
291	// Get fuzzy matches from history
292	matches, err := p.FuzzySearchHistory(input, 0.2) // Lower threshold for suggestions
293	if err != nil {
294		return nil, err
295	}
296
297	completions := make([]string, 0, limit)
298	seen := make(map[string]bool)
299
300	// Add URLs from fuzzy matches
301	for _, match := range matches {
302		if len(completions) >= limit {
303			break
304		}
305
306		url := match.HistoryEntry.Url
307		if !seen[url] {
308			completions = append(completions, url)
309			seen[url] = true
310		}
311	}
312
313	// Add search shortcuts if input looks like it might be a shortcut
314	if strings.Contains(input, ":") || len(input) <= 3 {
315		for shortcut, config := range p.config.SearchShortcuts {
316			if len(completions) >= limit {
317				break
318			}
319
320			if strings.HasPrefix(shortcut, strings.ToLower(input)) {
321				suggestion := fmt.Sprintf("%s: %s", shortcut, config.Description)
322				if !seen[suggestion] {
323					completions = append(completions, suggestion)
324					seen[suggestion] = true
325				}
326			}
327		}
328	}
329
330	return completions, nil
331}
332
333// ValidateConfig validates the parser configuration.
334func (p *Parser) ValidateConfig() error {
335	if p.config == nil {
336		return fmt.Errorf("parser config is nil")
337	}
338
339	if p.fuzzyConfig == nil {
340		return fmt.Errorf("fuzzy config is nil")
341	}
342
343	if !p.fuzzyConfig.IsValid() {
344		return fmt.Errorf("invalid fuzzy config")
345	}
346
347	if p.historyProvider == nil {
348		return fmt.Errorf("history provider is nil")
349	}
350
351	// Validate search shortcuts
352	if len(p.config.SearchShortcuts) == 0 {
353		return fmt.Errorf("no search shortcuts configured")
354	}
355
356	// Check for default search shortcut
357	if _, hasGoogle := p.config.SearchShortcuts["g"]; !hasGoogle {
358		// Warning: no default search configured
359	}
360
361	return nil
362}

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