v0.1 bf7c821

parser.go RawHistoryFolder

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

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