v0.7.0 31c7fae

binary_format.go RawHistoryFolder

1// Package cache provides binary format serialization for fuzzy cache data structures.
2package cache
3
4import (
5	"encoding/binary"
6	"fmt"
7	"log"
8	"os"
9	"syscall"
10	"unsafe"
11
12	"github.com/bnema/dumber/internal/logging"
13)
14
15// Binary cache format layout:
16// Header:    [magic:4][version:4][entryCount:4][indexOffset:8][lastModified:8]
17// Entries:   [url_len:2][url:N][title_len:2][title:N][visit:2][lastVisit:4][score:2]
18// Index:     [trigramCount:4][trigram:3][count:4][ids:4*count]...
19
20// Binary format constants for cache file structure
21const (
22	CacheMagic   = 0x44554d42 // "DUMB" in little-endian
23	CacheVersion = 1
24	HeaderSize   = 28 // Size of binary header
25)
26
27// BinaryHeader represents the cache file header.
28type BinaryHeader struct {
29	Magic        uint32 // Magic number for format identification
30	Version      uint32 // Cache format version
31	EntryCount   uint32 // Number of entries in cache
32	IndexOffset  uint64 // Offset to trigram index section
33	LastModified int64  // Unix timestamp of last modification
34}
35
36// SaveToBinary writes the cache to a binary file using memory mapping for performance.
37func (c *DmenuFuzzyCache) SaveToBinary(filename string) error {
38	c.mu.RLock()
39	defer c.mu.RUnlock()
40
41	// Calculate total file size
42	totalSize := c.calculateFileSize()
43
44	// Create the file with the exact size needed
45	file, err := os.Create(filename) //nolint:gosec // G304: filename is controlled by cache configuration, not user input
46	if err != nil {
47		return fmt.Errorf("failed to create cache file: %w", err)
48	}
49	defer func() {
50		if closeErr := file.Close(); closeErr != nil {
51			logging.Warn(fmt.Sprintf("failed to close cache file: %v", closeErr))
52		}
53	}()
54
55	// Pre-allocate file space
56	if err := file.Truncate(int64(totalSize)); err != nil {
57		return fmt.Errorf("failed to allocate file space: %w", err)
58	}
59
60	// Memory map the file for fast writing
61	data, err := syscall.Mmap(int(file.Fd()), 0, totalSize, syscall.PROT_WRITE, syscall.MAP_SHARED)
62	if err != nil {
63		return fmt.Errorf("failed to mmap file: %w", err)
64	}
65	defer func() {
66		if unmapErr := syscall.Munmap(data); unmapErr != nil {
67			logging.Warn(fmt.Sprintf("failed to unmap cache file: %v", unmapErr))
68		}
69	}()
70
71	// Write data using unsafe pointer operations for maximum speed
72	offset := 0
73
74	// Write header
75	offset = c.writeHeader(data, offset, totalSize)
76
77	// Write entries
78	entriesEndOffset := c.writeEntries(data, offset)
79
80	// Write trigram index
81	trigramEndOffset := c.writeTrigramIndex(data, entriesEndOffset)
82
83	// Write sorted index
84	c.writeSortedIndex(data, trigramEndOffset)
85
86	// Force sync to disk (Linux-specific, skip on error)
87	// syscall.Msync may not be available on all platforms
88	// _ = syscall.Msync(data, syscall.MS_SYNC)
89
90	return nil
91}
92
93// LoadFromBinary loads cache from a binary file using memory mapping.
94func (c *DmenuFuzzyCache) LoadFromBinary(filename string) error {
95	file, err := os.Open(filename) //nolint:gosec // G304: filename is controlled by cache configuration, not user input
96	if err != nil {
97		return fmt.Errorf("failed to open cache file: %w", err)
98	}
99	defer func() {
100		if closeErr := file.Close(); closeErr != nil {
101			logging.Warn(fmt.Sprintf("failed to close cache file: %v", closeErr))
102		}
103	}()
104
105	// Get file size
106	stat, err := file.Stat()
107	if err != nil {
108		return fmt.Errorf("failed to stat cache file: %w", err)
109	}
110	fileSize := int(stat.Size())
111
112	if fileSize < HeaderSize {
113		return fmt.Errorf("cache file too small: %d bytes", fileSize)
114	}
115
116	// Memory map the file for fast reading
117	data, err := syscall.Mmap(int(file.Fd()), 0, fileSize, syscall.PROT_READ, syscall.MAP_SHARED)
118	if err != nil {
119		return fmt.Errorf("failed to mmap cache file: %w", err)
120	}
121	// Note: We don't defer Munmap here as we want to keep the mapping alive
122	// The mapping will be cleaned up when the process exits
123
124	// Parse header
125	header, err := c.parseHeader(data)
126	if err != nil {
127		if unmapErr := syscall.Munmap(data); unmapErr != nil {
128			logging.Warn(fmt.Sprintf("failed to unmap cache file: %v", unmapErr))
129		}
130		return fmt.Errorf("failed to parse header: %w", err)
131	}
132
133	c.mu.Lock()
134	defer c.mu.Unlock()
135
136	// Parse entries
137	entries, err := c.parseEntries(data, HeaderSize, header.EntryCount)
138	if err != nil {
139		if unmapErr := syscall.Munmap(data); unmapErr != nil {
140			logging.Warn(fmt.Sprintf("failed to unmap cache file: %v", unmapErr))
141		}
142		return fmt.Errorf("failed to parse entries: %w", err)
143	}
144
145	// Parse trigram index
146	if header.IndexOffset > uint64(^uint(0)>>1) { // Check if it exceeds int max
147		return fmt.Errorf("index offset too large: %d", header.IndexOffset)
148	}
149	trigramIndex, trigramEndOffset, err := c.parseTrigramIndex(data, int(header.IndexOffset)) //nolint:gosec // G115: bounds checked above
150	if err != nil {
151		if unmapErr := syscall.Munmap(data); unmapErr != nil {
152			logging.Warn(fmt.Sprintf("failed to unmap cache file: %v", unmapErr))
153		}
154		return fmt.Errorf("failed to parse trigram index: %w", err)
155	}
156
157	// Parse sorted index
158	sortedIndex, err := c.parseSortedIndex(data, trigramEndOffset)
159	if err != nil {
160		if unmapErr := syscall.Munmap(data); unmapErr != nil {
161			logging.Warn(fmt.Sprintf("failed to unmap cache file: %v", unmapErr))
162		}
163		return fmt.Errorf("failed to parse sorted index: %w", err)
164	}
165
166	// Update cache state
167	c.entries = entries
168	c.trigramIndex = trigramIndex
169	c.sortedIndex = sortedIndex
170	c.entryCount = header.EntryCount
171	c.lastModified = header.LastModified
172	c.version = header.Version
173
174	return nil
175}
176
177// calculateFileSize estimates the total file size needed.
178func (c *DmenuFuzzyCache) calculateFileSize() int {
179	size := HeaderSize
180
181	// Calculate entries section size
182	for _, entry := range c.entries {
183		size += 2 + len(entry.URL)   // url_len + url
184		size += 2 + len(entry.Title) // title_len + title
185		size += 2 + 4 + 2            // visit + lastVisit + score
186	}
187
188	// Calculate trigram index size
189	size += 4 // trigram count
190	for trigram, ids := range c.trigramIndex {
191		size += len(trigram) + 4 + len(ids)*4 // trigram + count + ids
192	}
193
194	// Calculate sorted index size
195	size += 4 + len(c.sortedIndex)*4 // count + indices
196
197	return size
198}
199
200// writeHeader writes the binary header.
201func (c *DmenuFuzzyCache) writeHeader(data []byte, offset int, totalSize int) int {
202	// Log header info to file only (not stdout) to avoid interfering with dmenu
203	if logger := logging.GetLogger(); logger != nil {
204		logger.WriteFileOnly(logging.LogLevelInfo(), fmt.Sprintf("writing header: totalSize=%d bytes", totalSize), "CACHE")
205	}
206	// Calculate index offset (header + all entries)
207	indexOffset := HeaderSize
208	for _, entry := range c.entries {
209		indexOffset += 2 + len(entry.URL) + 2 + len(entry.Title) + 8
210	}
211
212	// Check bounds for integer conversions
213	if len(c.entries) > 0xFFFFFFFF { // uint32 max
214		logging.Warn(fmt.Sprintf("too many entries for binary format: %d, truncating", len(c.entries)))
215	}
216	if indexOffset < 0 {
217		log.Printf("Warning: negative index offset: %d, using 0", indexOffset)
218		indexOffset = 0
219	}
220
221	header := BinaryHeader{
222		Magic:        CacheMagic,
223		Version:      CacheVersion,
224		EntryCount:   uint32(len(c.entries)), //nolint:gosec // G115: bounds checked above
225		IndexOffset:  uint64(indexOffset),    //nolint:gosec // G115: bounds checked above
226		LastModified: c.lastModified,
227	}
228
229	// Write header using unsafe for maximum performance
230	*(*BinaryHeader)(unsafe.Pointer(&data[offset])) = header
231	return offset + HeaderSize
232}
233
234// writeEntries writes all cache entries in binary format.
235func (c *DmenuFuzzyCache) writeEntries(data []byte, offset int) int {
236	for _, entry := range c.entries {
237		// Write URL
238		urlLen := uint16(len(entry.URL))
239		*(*uint16)(unsafe.Pointer(&data[offset])) = urlLen
240		offset += 2
241		copy(data[offset:], entry.URL)
242		offset += len(entry.URL)
243
244		// Write Title
245		titleLen := uint16(len(entry.Title))
246		*(*uint16)(unsafe.Pointer(&data[offset])) = titleLen
247		offset += 2
248		copy(data[offset:], entry.Title)
249		offset += len(entry.Title)
250
251		// Write metadata
252		*(*uint16)(unsafe.Pointer(&data[offset])) = entry.VisitCount
253		offset += 2
254		*(*uint32)(unsafe.Pointer(&data[offset])) = entry.LastVisit
255		offset += 4
256		*(*uint16)(unsafe.Pointer(&data[offset])) = entry.Score
257		offset += 2
258	}
259	return offset
260}
261
262// writeTrigramIndex writes the trigram index in binary format.
263func (c *DmenuFuzzyCache) writeTrigramIndex(data []byte, offset int) int {
264	// Write trigram count
265	trigramCount := uint32(len(c.trigramIndex))
266	*(*uint32)(unsafe.Pointer(&data[offset])) = trigramCount
267	offset += 4
268
269	// Write trigrams and their entry lists
270	for trigram, ids := range c.trigramIndex {
271		// Write trigram (exactly 3 bytes)
272		copy(data[offset:offset+3], trigram)
273		offset += 3
274
275		// Write entry count
276		idCount := uint32(len(ids))
277		*(*uint32)(unsafe.Pointer(&data[offset])) = idCount
278		offset += 4
279
280		// Write entry IDs
281		for _, id := range ids {
282			*(*uint32)(unsafe.Pointer(&data[offset])) = id
283			offset += 4
284		}
285	}
286	return offset
287}
288
289// writeSortedIndex writes the sorted index in binary format.
290func (c *DmenuFuzzyCache) writeSortedIndex(data []byte, offset int) int {
291	// Write sorted index count
292	indexCount := uint32(len(c.sortedIndex))
293	*(*uint32)(unsafe.Pointer(&data[offset])) = indexCount
294	offset += 4
295
296	// Write sorted indices
297	for _, id := range c.sortedIndex {
298		*(*uint32)(unsafe.Pointer(&data[offset])) = id
299		offset += 4
300	}
301	return offset
302}
303
304// parseHeader parses the binary header from mapped data.
305func (c *DmenuFuzzyCache) parseHeader(data []byte) (*BinaryHeader, error) {
306	if len(data) < HeaderSize {
307		return nil, fmt.Errorf("insufficient data for header")
308	}
309
310	header := (*BinaryHeader)(unsafe.Pointer(&data[0]))
311
312	if header.Magic != CacheMagic {
313		return nil, fmt.Errorf("invalid magic number: 0x%x", header.Magic)
314	}
315
316	if header.Version != CacheVersion {
317		return nil, fmt.Errorf("unsupported cache version: %d", header.Version)
318	}
319
320	return header, nil
321}
322
323// parseEntries parses cache entries from binary data.
324func (c *DmenuFuzzyCache) parseEntries(data []byte, offset int, count uint32) ([]CompactEntry, error) {
325	entries := make([]CompactEntry, 0, count)
326
327	for i := uint32(0); i < count; i++ {
328		if offset+2 > len(data) {
329			return nil, fmt.Errorf("insufficient data for URL length at entry %d", i)
330		}
331
332		// Read URL
333		urlLen := *(*uint16)(unsafe.Pointer(&data[offset]))
334		offset += 2
335		if offset+int(urlLen) > len(data) {
336			return nil, fmt.Errorf("insufficient data for URL at entry %d", i)
337		}
338		url := string(data[offset : offset+int(urlLen)])
339		offset += int(urlLen)
340
341		// Read Title
342		if offset+2 > len(data) {
343			return nil, fmt.Errorf("insufficient data for title length at entry %d", i)
344		}
345		titleLen := *(*uint16)(unsafe.Pointer(&data[offset]))
346		offset += 2
347		if offset+int(titleLen) > len(data) {
348			return nil, fmt.Errorf("insufficient data for title at entry %d", i)
349		}
350		title := string(data[offset : offset+int(titleLen)])
351		offset += int(titleLen)
352
353		// Read metadata
354		if offset+8 > len(data) {
355			return nil, fmt.Errorf("insufficient data for metadata at entry %d", i)
356		}
357		visitCount := *(*uint16)(unsafe.Pointer(&data[offset]))
358		offset += 2
359		lastVisit := *(*uint32)(unsafe.Pointer(&data[offset]))
360		offset += 4
361		score := *(*uint16)(unsafe.Pointer(&data[offset]))
362		offset += 2
363
364		entries = append(entries, CompactEntry{
365			URL:        url,
366			Title:      title,
367			VisitCount: visitCount,
368			LastVisit:  lastVisit,
369			Score:      score,
370		})
371	}
372
373	return entries, nil
374}
375
376// parseTrigramIndex parses the trigram index from binary data.
377func (c *DmenuFuzzyCache) parseTrigramIndex(data []byte, offset int) (map[string][]uint32, int, error) {
378	if offset+4 > len(data) {
379		return nil, 0, fmt.Errorf("insufficient data for trigram count")
380	}
381
382	trigramCount := *(*uint32)(unsafe.Pointer(&data[offset]))
383	offset += 4
384
385	trigramIndex := make(map[string][]uint32, trigramCount)
386
387	for i := uint32(0); i < trigramCount; i++ {
388		// Read trigram
389		if offset+3 > len(data) {
390			return nil, 0, fmt.Errorf("insufficient data for trigram %d", i)
391		}
392		trigram := string(data[offset : offset+3])
393		offset += 3
394
395		// Read entry count
396		if offset+4 > len(data) {
397			return nil, 0, fmt.Errorf("insufficient data for entry count in trigram %d", i)
398		}
399		idCount := *(*uint32)(unsafe.Pointer(&data[offset]))
400		offset += 4
401
402		// Read entry IDs
403		if offset+int(idCount)*4 > len(data) {
404			return nil, 0, fmt.Errorf("insufficient data for entry IDs in trigram %d", i)
405		}
406
407		ids := make([]uint32, idCount)
408		for j := uint32(0); j < idCount; j++ {
409			ids[j] = *(*uint32)(unsafe.Pointer(&data[offset]))
410			offset += 4
411		}
412
413		trigramIndex[trigram] = ids
414	}
415
416	return trigramIndex, offset, nil
417}
418
419// parseSortedIndex parses the sorted index from binary data.
420func (c *DmenuFuzzyCache) parseSortedIndex(data []byte, offset int) ([]uint32, error) {
421	if offset+4 > len(data) {
422		return nil, fmt.Errorf("insufficient data for sorted index count")
423	}
424
425	indexCount := *(*uint32)(unsafe.Pointer(&data[offset]))
426	offset += 4
427
428	if offset+int(indexCount)*4 > len(data) {
429		return nil, fmt.Errorf("insufficient data for sorted index entries")
430	}
431
432	sortedIndex := make([]uint32, indexCount)
433	for i := uint32(0); i < indexCount; i++ {
434		sortedIndex[i] = *(*uint32)(unsafe.Pointer(&data[offset]))
435		offset += 4
436	}
437
438	return sortedIndex, nil
439}
440
441// IsValidCacheFile checks if a file is a valid cache file without fully loading it.
442func IsValidCacheFile(filename string) bool {
443	file, err := os.Open(filename)
444	if err != nil {
445		return false
446	}
447	defer func() {
448		if closeErr := file.Close(); closeErr != nil {
449			log.Printf("Warning: failed to close cache validation file: %v", closeErr)
450		}
451	}()
452
453	var header BinaryHeader
454	err = binary.Read(file, binary.LittleEndian, &header)
455	if err != nil {
456		return false
457	}
458
459	return header.Magic == CacheMagic && header.Version == CacheVersion
460}

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