mrmd-project 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/search.js ADDED
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Search module - Fuzzy search utilities
3
+ *
4
+ * Provides fuzzy matching for file paths.
5
+ *
6
+ * @module Search
7
+ */
8
+
9
+ /**
10
+ * Fuzzy match a query against a string
11
+ *
12
+ * @param {string} query - Search query
13
+ * @param {string} target - String to match against
14
+ * @returns {object} Match result with score and matched indices
15
+ *
16
+ * @example
17
+ * Search.fuzzyMatch('instal', 'installation')
18
+ * // Returns { score: 15, matches: [0, 1, 2, 3, 4, 5] }
19
+ *
20
+ * Search.fuzzyMatch('xyz', 'installation')
21
+ * // Returns { score: 0, matches: [] }
22
+ */
23
+ export function fuzzyMatch(query, target) {
24
+ if (!query) return { score: 0, matches: [] };
25
+
26
+ const queryLower = query.toLowerCase();
27
+ const targetLower = target.toLowerCase();
28
+
29
+ let queryIndex = 0;
30
+ let score = 0;
31
+ const matches = [];
32
+ let prevMatchIndex = -1;
33
+ let consecutive = 0;
34
+
35
+ for (let i = 0; i < target.length && queryIndex < query.length; i++) {
36
+ if (targetLower[i] === queryLower[queryIndex]) {
37
+ matches.push(i);
38
+
39
+ // Scoring
40
+ if (prevMatchIndex === i - 1) {
41
+ // Consecutive match
42
+ consecutive++;
43
+ score += 2 + consecutive;
44
+ } else {
45
+ consecutive = 0;
46
+ score += 1;
47
+ }
48
+
49
+ // Bonus for word boundaries
50
+ if (i === 0 || '/._- '.includes(target[i - 1])) {
51
+ score += 5;
52
+ }
53
+
54
+ // Bonus for camelCase
55
+ if (i > 0 && target[i] === target[i].toUpperCase() &&
56
+ target[i - 1] === target[i - 1].toLowerCase() &&
57
+ target[i] !== target[i].toLowerCase()) {
58
+ score += 3;
59
+ }
60
+
61
+ prevMatchIndex = i;
62
+ queryIndex++;
63
+ }
64
+ }
65
+
66
+ // All query characters must match
67
+ if (queryIndex !== query.length) {
68
+ return { score: 0, matches: [] };
69
+ }
70
+
71
+ return { score, matches };
72
+ }
73
+
74
+ /**
75
+ * Search files by full path
76
+ *
77
+ * Splits query into tokens and matches each against the path.
78
+ * Results are ranked by match quality.
79
+ *
80
+ * @param {string} query - Search query (space-separated tokens)
81
+ * @param {string[]} paths - Array of file paths
82
+ * @returns {object[]} Search results with scores and match info
83
+ *
84
+ * @example
85
+ * Search.files('thesis readme', ['/home/user/thesis/README.md', '/home/user/other/README.md'])
86
+ * // Returns [
87
+ * // { path: '/home/user/thesis/README.md', score: 25, ... },
88
+ * // { path: '/home/user/other/README.md', score: 10, ... }
89
+ * // ]
90
+ */
91
+ export function files(query, paths) {
92
+ if (!query.trim()) {
93
+ // Return all files with no scoring
94
+ return paths.map(path => ({
95
+ path,
96
+ score: 0,
97
+ nameMatches: [],
98
+ dirMatches: [],
99
+ ...parsePath(path),
100
+ }));
101
+ }
102
+
103
+ const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
104
+ const results = [];
105
+
106
+ for (const path of paths) {
107
+ const pathLower = path.toLowerCase();
108
+ const { name, dir } = parsePath(path);
109
+
110
+ let totalScore = 0;
111
+ let allMatched = true;
112
+ const nameMatches = [];
113
+ const dirMatches = [];
114
+
115
+ for (const token of tokens) {
116
+ // Try matching against name first (higher weight)
117
+ const nameResult = fuzzyMatch(token, name);
118
+ if (nameResult.score > 0) {
119
+ totalScore += nameResult.score * 2; // Name matches worth more
120
+ nameMatches.push(...nameResult.matches);
121
+ continue;
122
+ }
123
+
124
+ // Try matching against full path
125
+ const pathResult = fuzzyMatch(token, path);
126
+ if (pathResult.score > 0) {
127
+ totalScore += pathResult.score;
128
+
129
+ // Split matches into dir and name portions
130
+ const dirLength = path.lastIndexOf('/') + 1;
131
+ for (const idx of pathResult.matches) {
132
+ if (idx < dirLength) {
133
+ dirMatches.push(idx);
134
+ } else {
135
+ nameMatches.push(idx - dirLength);
136
+ }
137
+ }
138
+ continue;
139
+ }
140
+
141
+ // Token didn't match
142
+ allMatched = false;
143
+ break;
144
+ }
145
+
146
+ if (allMatched && totalScore > 0) {
147
+ results.push({
148
+ path,
149
+ score: totalScore,
150
+ name,
151
+ dir,
152
+ nameMatches: [...new Set(nameMatches)].sort((a, b) => a - b),
153
+ dirMatches: [...new Set(dirMatches)].sort((a, b) => a - b),
154
+ });
155
+ }
156
+ }
157
+
158
+ // Sort by score descending
159
+ results.sort((a, b) => b.score - a.score);
160
+
161
+ return results;
162
+ }
163
+
164
+ /**
165
+ * Parse a path into name and directory components
166
+ * @private
167
+ */
168
+ function parsePath(path) {
169
+ // Simplify home directory
170
+ const displayPath = path.replace(/^\/home\/[^/]+/, '~');
171
+ const lastSlash = displayPath.lastIndexOf('/');
172
+
173
+ return {
174
+ name: lastSlash >= 0 ? displayPath.slice(lastSlash + 1) : displayPath,
175
+ dir: lastSlash >= 0 ? displayPath.slice(0, lastSlash) : '',
176
+ };
177
+ }