@oscarpalmer/atoms 0.219.0 → 0.221.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.
@@ -3,8 +3,7 @@ import { floor } from "../internal/math/misc.mjs";
3
3
  import { max } from "../internal/math/aggregate.mjs";
4
4
  import { getString } from "../internal/string/misc.mjs";
5
5
  import { lowerCase } from "../internal/string/case.mjs";
6
- import { FUZZY_MESSAGE_ARRAY, FUZZY_MESSAGE_HANDLER, FUZZY_PROPERTY, FUZZY_SYMBOL } from "../models/string/string.fuzzy.model.mjs";
7
- import { includes } from "./match.mjs";
6
+ import { FUZZY_BOUNDARY_SEPARATOR, FUZZY_MESSAGE_ARRAY, FUZZY_MESSAGE_HANDLER, FUZZY_PROPERTY, FUZZY_SYMBOL } from "../models/string/string.fuzzy.model.mjs";
8
7
  //#region src/string/fuzzy.ts
9
8
  function Fuzzy(state) {
10
9
  this[FUZZY_SYMBOL] = state;
@@ -14,39 +13,76 @@ Fuzzy.prototype.search = search;
14
13
  Object.defineProperties(Fuzzy.prototype, {
15
14
  items: {
16
15
  enumerable: true,
17
- get: getFuzzyItems,
18
- set: setFuzzyItems
16
+ get: getItems,
17
+ set: setItems
19
18
  },
20
19
  strings: {
21
20
  enumerable: true,
22
- get: getFuzzyStrings
21
+ get: getStrings
23
22
  }
24
23
  });
25
- function createFuzzyItems(items) {
26
- return items.toSorted((first, second) => first.haystack.localeCompare(second.haystack)).map(({ item }) => item);
24
+ function compareItem(sorter) {
25
+ return (first, second) => sorter(first.string.original, second.string.original);
27
26
  }
28
- function createFuzzyOptions(input, state) {
29
- const options = isPlainObject(input) ? input : {};
30
- const limit = typeof input === "number" ? input : options.limit;
27
+ function compareRanked(sorter) {
28
+ return (first, second) => first.rank - second.rank || first.position - second.position || sorter(first.string.original, second.string.original);
29
+ }
30
+ function createItems(state, items) {
31
+ state.items.original = items.map((item) => ({
32
+ data: item,
33
+ string: createString(state.handler(item))
34
+ }));
35
+ state.items.sorted = void 0;
36
+ }
37
+ function createOptions(input, state) {
38
+ const options = {};
39
+ const original = isPlainObject(input) ? input : {};
40
+ const limit = typeof input === "number" ? input : original.limit;
31
41
  if (typeof limit === "number" && !Number.isNaN(limit) && limit >= 1) options.limit = floor(limit);
32
- else options.limit = state?.limit;
33
- options.tolerance = getTolerance(options.tolerance, state);
42
+ else options.limit = state?.limit ?? Number.POSITIVE_INFINITY;
43
+ options.tolerance = getTolerance(original.tolerance, state);
34
44
  return options;
35
45
  }
36
- function createFuzzyState(items, input) {
37
- const handler = getHandler(input);
38
- const options = createFuzzyOptions(input);
46
+ function createRanked(item, needle, position) {
47
+ let boundary = position;
48
+ while (boundary > -1 && !isBoundary(item.string, boundary)) boundary = item.string.lowercase.indexOf(needle, boundary + 1);
49
+ if (boundary === -1) return {
50
+ ...item,
51
+ position,
52
+ rank: 2
53
+ };
39
54
  return {
55
+ ...item,
56
+ position: boundary,
57
+ rank: boundary === 0 ? 0 : 1
58
+ };
59
+ }
60
+ function createState(items, input) {
61
+ const handler = getHandler(input);
62
+ const options = createOptions(input);
63
+ const sorter = getComparator(input);
64
+ const state = {
65
+ compare: {
66
+ item: compareItem(sorter),
67
+ ranked: compareRanked(sorter)
68
+ },
40
69
  handler,
41
- items: items.slice(),
70
+ items: { original: [] },
42
71
  limit: options.limit,
43
- strings: items.map(handler),
44
72
  tolerance: options.tolerance
45
73
  };
74
+ createItems(state, items);
75
+ return state;
76
+ }
77
+ function createString(original) {
78
+ return {
79
+ original,
80
+ lowercase: lowerCase(original)
81
+ };
46
82
  }
47
83
  function fuzzy(items, configuration) {
48
84
  if (!Array.isArray(items)) throw new TypeError(FUZZY_MESSAGE_ARRAY);
49
- return new Fuzzy(createFuzzyState(items, configuration));
85
+ return new Fuzzy(createState(items, configuration));
50
86
  }
51
87
  /**
52
88
  * Does the needle match the haystack in a fuzzy way?
@@ -60,53 +96,117 @@ function fuzzy(items, configuration) {
60
96
  function fuzzyMatch(haystack, needle) {
61
97
  if (typeof haystack !== "string" || typeof needle !== "string") return false;
62
98
  const trimmed = needle.trim();
63
- if (includes(haystack, trimmed, true)) return true;
64
- return getScore(haystack, trimmed) > -1;
99
+ const lowercaseHaystack = lowerCase(haystack);
100
+ const lowercaseNeedle = lowerCase(trimmed);
101
+ if (lowercaseHaystack.includes(lowercaseNeedle)) return true;
102
+ return getScore({
103
+ lowercase: lowercaseHaystack,
104
+ original: haystack
105
+ }, lowercaseNeedle) > -1;
106
+ }
107
+ function getComparator(input) {
108
+ if (isPlainObject(input) && typeof input.compare === "function") return input.compare;
109
+ defaultSorter ??= Intl.Collator().compare;
110
+ return defaultSorter;
65
111
  }
66
112
  function getHandler(input) {
67
113
  if (input == null || input === getString) return getString;
68
114
  switch (typeof input) {
69
- case "function": return input;
70
- case "string": return (item) => item[input];
115
+ case "function": return (item) => getString(input(item));
116
+ case "number":
117
+ case "string":
118
+ case "symbol": return (item) => getString(item?.[input]);
71
119
  default:
72
120
  if (isPlainObject(input)) return getHandler(input.key ?? input.handler);
73
121
  throw new TypeError(FUZZY_MESSAGE_HANDLER);
74
122
  }
75
123
  }
76
- function getFuzzyItems() {
77
- return this[FUZZY_SYMBOL].items.slice();
78
- }
79
- function getFuzzyStrings() {
80
- return this[FUZZY_SYMBOL].strings.slice();
124
+ function getItems() {
125
+ return this[FUZZY_SYMBOL].items.original.map((item) => item.data);
81
126
  }
82
- function getScore(haystack, needle) {
83
- if (!isSubsequence(haystack, needle)) return -1;
84
- const lowerCaseHaystack = lowerCase(haystack);
85
- const lowerCaseNeedle = lowerCase(needle);
86
- const needleLength = lowerCaseNeedle.length;
127
+ function getMatchEnd(haystack, needle, cursor) {
128
+ const haystackLength = haystack.length;
129
+ const needleLength = needle.length;
87
130
  let needleIndex = 0;
88
- let previousMatchIndex = -1;
89
- let score = 0;
90
- for (let haystackIndex = 0; haystackIndex < lowerCaseHaystack.length; haystackIndex += 1) {
91
- if (lowerCaseHaystack[haystackIndex] === lowerCaseNeedle[needleIndex]) {
92
- score += 1;
93
- if (haystackIndex === 0) score += 1;
94
- if (previousMatchIndex !== -1) {
95
- const gap = haystackIndex - previousMatchIndex - 1;
96
- score += max([0, 5 - gap]);
131
+ for (let index = cursor; index < haystackLength; index += 1) if (haystack[index] === needle[needleIndex]) {
132
+ needleIndex += 1;
133
+ if (needleIndex === needleLength) return index;
134
+ }
135
+ return -1;
136
+ }
137
+ function getResult(state, exact, included, scored, options) {
138
+ const result = {
139
+ exact: [],
140
+ included: [],
141
+ similar: []
142
+ };
143
+ exact.sort(state.compare.item);
144
+ result.exact = exact.slice(0, options.limit).map((item) => item.data);
145
+ included.sort(state.compare.ranked);
146
+ result.included = included.slice(0, options.limit - result.exact.length).map((item) => item.data);
147
+ const remaining = options.limit - result.exact.length - result.included.length;
148
+ if (remaining === 0) return result;
149
+ const keys = Object.keys(scored).map(Number).sort((first, second) => second - first);
150
+ const keysLength = keys.length;
151
+ if (keysLength === 0) return result;
152
+ const maxScore = keys[0];
153
+ outer: for (let index = 0; index < keysLength; index += 1) {
154
+ const key = keys[index];
155
+ if (maxScore - key > options.tolerance) break;
156
+ const keyed = scored[key];
157
+ const keyedLength = keyed.length;
158
+ keyed.sort(state.compare.item);
159
+ for (let keyedIndex = 0; keyedIndex < keyedLength; keyedIndex += 1) {
160
+ result.similar.push(keyed[keyedIndex].data);
161
+ if (result.similar.length >= remaining) break outer;
162
+ }
163
+ }
164
+ return result;
165
+ }
166
+ function getScore(item, needle) {
167
+ const haystackLength = item.lowercase.length;
168
+ const needleLength = needle.length;
169
+ if (needleLength > haystackLength) return -1;
170
+ let best = -1;
171
+ let cursor = 0;
172
+ while (cursor < haystackLength) {
173
+ const end = getMatchEnd(item.lowercase, needle, cursor);
174
+ if (end === -1) break;
175
+ let index = end;
176
+ let needleIndex = needleLength - 1;
177
+ let next = -1;
178
+ let score = 0;
179
+ let start = end;
180
+ while (needleIndex >= 0) {
181
+ if (item.lowercase[index] === needle[needleIndex]) {
182
+ score += 1;
183
+ if (next !== -1) score += max([0, 5 - (next - index - 1)]);
184
+ if (isBoundary(item, index)) score += 2;
185
+ next = index;
186
+ start = index;
187
+ needleIndex -= 1;
97
188
  }
98
- previousMatchIndex = haystackIndex;
99
- needleIndex += 1;
189
+ index -= 1;
100
190
  }
101
- if (needleIndex === needleLength) break;
191
+ if (start === 0) score += 1;
192
+ if (score > best) best = score;
193
+ cursor = start + 1;
102
194
  }
103
- score -= floor(lowerCaseHaystack.length / 3);
104
- return max([0, score]);
195
+ if (best === -1) return -1;
196
+ return max([0, best - floor(haystackLength / 3)]);
197
+ }
198
+ function getStrings() {
199
+ return this[FUZZY_SYMBOL].items.original.map((item) => item.string.original);
105
200
  }
106
201
  function getTolerance(input, state) {
107
202
  if (typeof input === "number" && !Number.isNaN(input)) return input < 0 ? 0 : floor(input);
108
203
  return state?.tolerance ?? 5;
109
204
  }
205
+ function isBoundary(string, index) {
206
+ if (index === 0 || FUZZY_BOUNDARY_SEPARATOR.test(string.lowercase[index - 1])) return true;
207
+ if (string.original.length !== string.lowercase.length) return false;
208
+ return string.original[index] !== string.lowercase[index] && string.original[index - 1] === string.lowercase[index - 1];
209
+ }
110
210
  /**
111
211
  * Is the value a fuzzy searcher?
112
212
  *
@@ -118,74 +218,48 @@ function getTolerance(input, state) {
118
218
  function isFuzzy(value) {
119
219
  return typeof value === "object" && value !== null && "$fuzzy" in value && value["$fuzzy"] === true;
120
220
  }
121
- function isSubsequence(haystack, needle) {
122
- const lowerCaseHaystack = lowerCase(haystack);
123
- const lowerCaseNeedle = lowerCase(needle);
124
- const haystackLength = lowerCaseHaystack.length;
125
- const needleLength = lowerCaseNeedle.length;
126
- let needleIndex = 0;
127
- for (let haystackIndex = 0; haystackIndex < haystackLength; haystackIndex += 1) {
128
- if (lowerCaseHaystack[haystackIndex] === lowerCaseNeedle[needleIndex]) needleIndex += 1;
129
- if (needleIndex === needleLength) return true;
130
- }
131
- return false;
132
- }
133
221
  function search(input, option) {
134
222
  const state = this[FUZZY_SYMBOL];
135
- const options = option == null ? state : createFuzzyOptions(option, state);
136
- const result = {
137
- exact: [],
138
- similar: []
139
- };
223
+ const options = option == null ? state : createOptions(option, state);
140
224
  const value = typeof input === "string" ? input.trim() : "";
225
+ const lowercaseValue = lowerCase(value);
141
226
  if (value.length === 0) {
142
- result.exact = state.items.slice(0, options.limit);
143
- return result;
227
+ state.items.sorted ??= state.items.original.toSorted(state.compare.item);
228
+ return {
229
+ exact: state.items.sorted.slice(0, options.limit).map((item) => item.data),
230
+ included: [],
231
+ similar: []
232
+ };
144
233
  }
145
- let { length } = state.items;
146
234
  const exact = [];
147
- const similar = [];
235
+ const included = [];
148
236
  const scored = {};
237
+ const { length } = state.items.original;
149
238
  for (let index = 0; index < length; index += 1) {
150
- const item = state.items[index];
151
- const haystack = state.strings[index];
152
- if (includes(haystack, value, true)) {
153
- exact.push({
154
- item,
155
- haystack
156
- });
239
+ const item = state.items.original[index];
240
+ const { string } = item;
241
+ if (string.lowercase === lowercaseValue) {
242
+ exact.push(item);
157
243
  continue;
158
244
  }
159
- const score = getScore(haystack, value);
245
+ const position = string.lowercase.indexOf(lowercaseValue);
246
+ if (position > -1) {
247
+ included.push(createRanked(item, lowercaseValue, position));
248
+ continue;
249
+ }
250
+ const score = getScore(string, lowercaseValue);
160
251
  if (score > -1) {
161
252
  scored[score] ??= [];
162
- scored[score].push({
163
- item,
164
- haystack
165
- });
253
+ scored[score].push(item);
166
254
  }
167
255
  }
168
- const keys = Object.keys(scored).map(Number).sort((first, second) => second - first);
169
- length = keys.length;
170
- if (length > 0 && options.tolerance > 0) {
171
- const maxScore = keys[0];
172
- for (let index = 0; index < length; index += 1) {
173
- const key = keys[index];
174
- if (maxScore - key > options.tolerance) break;
175
- similar.push(...createFuzzyItems(scored[key]));
176
- }
177
- }
178
- result.exact = createFuzzyItems(options.limit == null ? exact : exact.slice(0, options.limit));
179
- if (options.limit == null) result.similar = similar;
180
- else result.similar = similar.slice(0, options.limit - result.exact.length);
181
- return result;
256
+ return getResult(state, exact, included, scored, options);
182
257
  }
183
- function setFuzzyItems(value) {
258
+ function setItems(value) {
184
259
  if (!Array.isArray(value)) throw new TypeError(FUZZY_MESSAGE_ARRAY);
185
- const state = this[FUZZY_SYMBOL];
186
- state.items = value.slice();
187
- state.strings = value.map((item) => state.handler(item));
260
+ createItems(this[FUZZY_SYMBOL], value);
188
261
  }
262
+ let defaultSorter;
189
263
  fuzzy.is = isFuzzy;
190
264
  fuzzy.match = fuzzyMatch;
191
265
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oscarpalmer/atoms",
3
- "version": "0.219.0",
3
+ "version": "0.221.0",
4
4
  "description": "Atomic utilities for making your JavaScript better.",
5
5
  "keywords": [
6
6
  "array",