@oscarpalmer/atoms 0.219.0 → 0.220.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/dist/index.d.mts CHANGED
@@ -6367,26 +6367,28 @@ type Fuzzy<Item> = {
6367
6367
  *
6368
6368
  * @param value Value to search for
6369
6369
  * @param options Search options
6370
- * @returns Search results, with exact matches _(ordered)_ and similar matches _(ordered by relevance)_
6370
+ * @returns Search results, with exact, included, and similar matches
6371
6371
  */
6372
6372
  search(value: string, options?: FuzzyOptions): FuzzyResult<Item>;
6373
6373
  /**
6374
6374
  * Search for items matching the given value
6375
6375
  *
6376
6376
  * @param value Value to search for
6377
- * @param limit Maximum number of combined items to return in `exact` and `similar`
6378
- * @returns Search results, with exact matches _(ordered)_ and similar matches _(ordered by relevance)_
6377
+ * @param limit Maximum number of combined items to return in `exact`, `included`, and `similar`
6378
+ * @returns Search results, with exact, included, and similar matches
6379
6379
  */
6380
6380
  search(value: string, limit: number): FuzzyResult<Item>;
6381
6381
  };
6382
6382
  type FuzzyConfiguration<Item> = {
6383
+ handler?: (item: Item) => string;
6383
6384
  /**
6384
- * Handler to stringify items
6385
+ * Function to compare the stringified items for results _(defaults to `Intl.Collator().compare`)_
6385
6386
  *
6386
- * - May be a function that takes an item and returns a string, or if items are plain objects, a key of the item to use to grab a string value from
6387
- * - Defaults to `getString`
6387
+ * @param first First string to compare
6388
+ * @param second Second string to compare
6389
+ * @returns Comparison result, similar to `Array.prototype.sort` callback
6388
6390
  */
6389
- handler?: (item: Item) => string;
6391
+ compare?: (first: string, second: string) => number;
6390
6392
  } & (Item extends PlainObject ? {
6391
6393
  /**
6392
6394
  * Key to use to stringify items
@@ -6395,13 +6397,13 @@ type FuzzyConfiguration<Item> = {
6395
6397
  */
6396
6398
  key?: keyof Item;
6397
6399
  } : {}) & FuzzyOptions;
6398
- type FuzzyItem<Item> = {
6399
- item: Item;
6400
- haystack: string;
6400
+ type FuzzyItem<Data> = {
6401
+ data: Data;
6402
+ string: FuzzyString;
6401
6403
  };
6402
6404
  type FuzzyOptions = {
6403
6405
  /**
6404
- * Maximum number of combined items to return in `exact` and `similar` _(defaults to all matches)_
6406
+ * Maximum number of combined items to return in `exact`, `included`, and `similar` _(defaults to all matches)_
6405
6407
  */
6406
6408
  limit?: number;
6407
6409
  /**
@@ -6412,29 +6414,56 @@ type FuzzyOptions = {
6412
6414
  */
6413
6415
  tolerance?: number;
6414
6416
  };
6417
+ type FuzzyRanked<Data> = {
6418
+ position: number;
6419
+ /**
6420
+ * `0` for starting with the value, `1` for the value starting a word, `2` for anything else
6421
+ */
6422
+ rank: number;
6423
+ } & FuzzyItem<Data>;
6415
6424
  /**
6416
- * Search results from a fuzzy search, with exact and similar matches
6425
+ * Search results from a fuzzy search, with exact, included, and similar matches
6417
6426
  */
6418
6427
  type FuzzyResult<Item> = {
6419
6428
  /**
6420
- * Ordered items that exactly match the search value
6429
+ * Items whose strings exactly match the search value, ordered alphabetically
6421
6430
  */
6422
6431
  exact: Item[];
6423
6432
  /**
6424
- * Ordered items that are similar to the search value, ranked by relevance
6433
+ * Items whose strings contain the search value, ordered alphabetically
6434
+ */
6435
+ included: Item[];
6436
+ /**
6437
+ * Items whose strings are similar to the search value, ranked by relevance, sorted by score, and ordered alphabetically within the same score
6425
6438
  */
6426
6439
  similar: Item[];
6427
6440
  };
6428
6441
  type FuzzyState<Item> = {
6442
+ compare: FuzzyStateCompare<Item>;
6429
6443
  handler(item: Item): string;
6430
- items: Item[];
6431
- limit?: number;
6432
- strings: string[];
6444
+ items: FuzzyStateItems<Item>;
6445
+ limit: number;
6433
6446
  tolerance: number;
6434
6447
  };
6448
+ type FuzzyStateCompare<Item = unknown> = {
6449
+ item: (first: FuzzyItem<Item>, second: FuzzyItem<Item>) => number;
6450
+ ranked: (first: FuzzyRanked<Item>, second: FuzzyRanked<Item>) => number;
6451
+ };
6452
+ type FuzzyStateItems<Item> = {
6453
+ original: Array<FuzzyItem<Item>>;
6454
+ sorted?: Array<FuzzyItem<Item>>;
6455
+ };
6456
+ type FuzzyString = {
6457
+ lowercase: string;
6458
+ original: string;
6459
+ };
6460
+ type InternalFuzzy<Item = unknown> = {
6461
+ [FUZZY_SYMBOL]: FuzzyState<Item>;
6462
+ } & Fuzzy<Item>;
6463
+ declare const FUZZY_SYMBOL: unique symbol;
6435
6464
  //#endregion
6436
6465
  //#region src/string/fuzzy.d.ts
6437
- declare function Fuzzy(this: any, state: FuzzyState<unknown>): void;
6466
+ declare function Fuzzy(this: InternalFuzzy, state: FuzzyState<unknown>): void;
6438
6467
  /**
6439
6468
  * Create a fuzzy searcher for an array of items
6440
6469
  *
@@ -9453,4 +9482,4 @@ export declare class SizedSet<Value = unknown> extends Set<Value> {
9453
9482
  get(value: Value, update?: boolean): Value | undefined;
9454
9483
  }
9455
9484
  //#endregion
9456
- export { type ArrayComparisonSorter, type ArrayKeySorter, type ArrayKeySorters, type ArraySorter, type ArraySorters, type ArrayValueSorter, type Asserter, type AssignOptions, type Assigner, type AsyncFailure, type AsyncFlow, type AsyncLimiter, type AsyncOnce, type AsyncPlan, type AsyncPlanned, type AsyncStep, type AsyncSuccess, type Beacon, type BeaconOptions, type CloneOptions, type Cloner, type Color, type ColorType, type ColorTypeExtended, type ColorValue, type ColorValueExtended, type CompareCallback, type ComparisonSorter, type DiffOptions, type DiffResult, type EqualOptions, type Equalizer, type Failure, type Flow, type FulfilledPromise, type Fuzzy, type FuzzyItem, type FuzzyOptions, type FuzzyResult, type HSLAColor, type HSLColor, type HWBAColor, type HWBColor, type Herald, type HeraldEvents, type HeraldOptions, Key$1 as Key, type KeyedQueue, type Limiter, Logger, type Memoized, type MemoizedOptions, type MergeOptions, type Merger, type NormalizeOptions, type Normalizer, type Observable, type Observer, type Once, type Plan, type PlanError, type PlanOk, type PlanReturned, type Planned, type PromiseOptions, type PromiseStrategy, type PromisesOptions, type Queue, type QueueOptions, type Queued, type RGBAColor, type RGBColor, type RejectedPromise, type Renderer, type Shaken, type Smushed, type SortDirection, type Sorter, type Step, type Subscription, type SubscriptionParameters, type SubscriptionProperty, type Subscriptions, type SubscriptionsParameters, type Success, type TemplateOptions, type Templater, type TemplaterOptions, type TemplaterRenderer, type TimeLogger, type TransformCallback, type TransformCallbacks, type Transformer, type Unsmushed, push as append, filterAndMap as select, toPromise as fromResult, getRandomFloatingNumber as getRandomFloat, isLogger, isTimeLogger, mapAndFilter as reverseSelect };
9485
+ export { type ArrayComparisonSorter, type ArrayKeySorter, type ArrayKeySorters, type ArraySorter, type ArraySorters, type ArrayValueSorter, type Asserter, type AssignOptions, type Assigner, type AsyncFailure, type AsyncFlow, type AsyncLimiter, type AsyncOnce, type AsyncPlan, type AsyncPlanned, type AsyncStep, type AsyncSuccess, type Beacon, type BeaconOptions, type CloneOptions, type Cloner, type Color, type ColorType, type ColorTypeExtended, type ColorValue, type ColorValueExtended, type CompareCallback, type ComparisonSorter, type DiffOptions, type DiffResult, type EqualOptions, type Equalizer, type Failure, type Flow, type FulfilledPromise, type Fuzzy, type FuzzyOptions, type FuzzyResult, type HSLAColor, type HSLColor, type HWBAColor, type HWBColor, type Herald, type HeraldEvents, type HeraldOptions, Key$1 as Key, type KeyedQueue, type Limiter, Logger, type Memoized, type MemoizedOptions, type MergeOptions, type Merger, type NormalizeOptions, type Normalizer, type Observable, type Observer, type Once, type Plan, type PlanError, type PlanOk, type PlanReturned, type Planned, type PromiseOptions, type PromiseStrategy, type PromisesOptions, type Queue, type QueueOptions, type Queued, type RGBAColor, type RGBColor, type RejectedPromise, type Renderer, type Shaken, type Smushed, type SortDirection, type Sorter, type Step, type Subscription, type SubscriptionParameters, type SubscriptionProperty, type Subscriptions, type SubscriptionsParameters, type Success, type TemplateOptions, type Templater, type TemplaterOptions, type TemplaterRenderer, type TimeLogger, type TransformCallback, type TransformCallbacks, type Transformer, type Unsmushed, push as append, filterAndMap as select, toPromise as fromResult, getRandomFloatingNumber as getRandomFloat, isLogger, isTimeLogger, mapAndFilter as reverseSelect };
package/dist/index.mjs CHANGED
@@ -3728,79 +3728,12 @@ function attemptPipe(initial, first, ...seconds) {
3728
3728
  attemptPipe.async = attemptAsyncPipe;
3729
3729
  //#endregion
3730
3730
  //#region src/models/string/string.fuzzy.model.ts
3731
+ const FUZZY_BOUNDARY_SEPARATOR = /[\s./:\\_-]/;
3731
3732
  const FUZZY_MESSAGE_ARRAY = "Fuzzy requires an array of items";
3732
3733
  const FUZZY_MESSAGE_HANDLER = "Fuzzy requires a key or function to stringify items";
3733
3734
  const FUZZY_PROPERTY = "$fuzzy";
3734
3735
  const FUZZY_SYMBOL = Symbol(FUZZY_PROPERTY);
3735
3736
  //#endregion
3736
- //#region src/string/match.ts
3737
- /**
3738
- * Check if a string ends with a specified substring
3739
- *
3740
- * @param haystack String to look in
3741
- * @param needle String to look for
3742
- * @param ignoreCase Ignore case when matching? _(defaults to `false`)_
3743
- * @returns `true` if the string ends with the given substring, otherwise `false`
3744
- *
3745
- * @example
3746
- * ```typescript
3747
- * endsWith('this is a test', 'test'); // => true
3748
- * endsWith('this is a test', 'Test'); // => false
3749
- * endsWith('this is a test', 'Test', true); // => true
3750
- * endsWith('this is a test', 'not'); // => false
3751
- * ```
3752
- */
3753
- function endsWith(haystack, needle, ignoreCase) {
3754
- return match(STRING_MATCH_ENDS_WITH, haystack, needle, ignoreCase === true);
3755
- }
3756
- /**
3757
- * Check if a string includes a specified substring
3758
- *
3759
- * @param haystack String to look in
3760
- * @param needle String to look for
3761
- * @param ignoreCase Ignore case when matching? _(defaults to `false`)_
3762
- * @returns `true` if the string includes the given substring, otherwise `false`
3763
- *
3764
- * @example
3765
- * ```typescript
3766
- * includes('this is a test', 'is'); // => true
3767
- * includes('this is a test', 'Is'); // => false
3768
- * includes('this is a test', 'Is', true); // => true
3769
- * includes('this is a test', 'not'); // => false
3770
- * ```
3771
- */
3772
- function includes(haystack, needle, ignoreCase) {
3773
- return match(STRING_MATCH_INCLUDES, haystack, needle, ignoreCase === true);
3774
- }
3775
- function match(type, haystack, needle, ignoreCase) {
3776
- if (typeof haystack !== "string" || typeof needle !== "string") return false;
3777
- matchMemoizers[type] ??= memoize(matchCallback.bind(type));
3778
- return matchMemoizers[type].run(haystack, needle, ignoreCase);
3779
- }
3780
- function matchCallback(haystack, needle, ignoreCase) {
3781
- return (ignoreCase ? haystack.toLocaleLowerCase() : haystack)[this](ignoreCase ? needle.toLocaleLowerCase() : needle);
3782
- }
3783
- /**
3784
- * Check if a string starts with a specified substring
3785
- *
3786
- * @param haystack String to look in
3787
- * @param needle String to look for
3788
- * @param ignoreCase Ignore case when matching? _(defaults to `false`)_
3789
- * @returns `true` if the string starts with the given substring, otherwise `false`
3790
- *
3791
- * @example
3792
- * ```typescript
3793
- * startsWith('this is a test', 'this'); // => true
3794
- * startsWith('this is a test', 'This'); // => false
3795
- * startsWith('this is a test', 'This', true); // => true
3796
- * startsWith('this is a test', 'not'); // => false
3797
- * ```
3798
- */
3799
- function startsWith(haystack, needle, ignoreCase) {
3800
- return match(STRING_MATCH_STARTS_WITH, haystack, needle, ignoreCase === true);
3801
- }
3802
- const matchMemoizers = {};
3803
- //#endregion
3804
3737
  //#region src/string/fuzzy.ts
3805
3738
  function Fuzzy(state) {
3806
3739
  this[FUZZY_SYMBOL] = state;
@@ -3810,39 +3743,76 @@ Fuzzy.prototype.search = search;
3810
3743
  Object.defineProperties(Fuzzy.prototype, {
3811
3744
  items: {
3812
3745
  enumerable: true,
3813
- get: getFuzzyItems,
3814
- set: setFuzzyItems
3746
+ get: getItems,
3747
+ set: setItems
3815
3748
  },
3816
3749
  strings: {
3817
3750
  enumerable: true,
3818
- get: getFuzzyStrings
3751
+ get: getStrings
3819
3752
  }
3820
3753
  });
3821
- function createFuzzyItems(items) {
3822
- return items.toSorted((first, second) => first.haystack.localeCompare(second.haystack)).map(({ item }) => item);
3754
+ function compareItem(sorter) {
3755
+ return (first, second) => sorter(first.string.original, second.string.original);
3823
3756
  }
3824
- function createFuzzyOptions(input, state) {
3825
- const options = isPlainObject(input) ? input : {};
3826
- const limit = typeof input === "number" ? input : options.limit;
3757
+ function compareRanked(sorter) {
3758
+ return (first, second) => first.rank - second.rank || first.position - second.position || sorter(first.string.original, second.string.original);
3759
+ }
3760
+ function createItems(state, items) {
3761
+ state.items.original = items.map((item) => ({
3762
+ data: item,
3763
+ string: createString(state.handler(item))
3764
+ }));
3765
+ state.items.sorted = void 0;
3766
+ }
3767
+ function createOptions(input, state) {
3768
+ const options = {};
3769
+ const original = isPlainObject(input) ? input : {};
3770
+ const limit = typeof input === "number" ? input : original.limit;
3827
3771
  if (typeof limit === "number" && !Number.isNaN(limit) && limit >= 1) options.limit = floor(limit);
3828
- else options.limit = state?.limit;
3829
- options.tolerance = getTolerance(options.tolerance, state);
3772
+ else options.limit = state?.limit ?? Number.POSITIVE_INFINITY;
3773
+ options.tolerance = getTolerance(original.tolerance, state);
3830
3774
  return options;
3831
3775
  }
3832
- function createFuzzyState(items, input) {
3833
- const handler = getHandler(input);
3834
- const options = createFuzzyOptions(input);
3776
+ function createRanked(item, needle, position) {
3777
+ let boundary = position;
3778
+ while (boundary > -1 && !isBoundary(item.string, boundary)) boundary = item.string.lowercase.indexOf(needle, boundary + 1);
3779
+ if (boundary === -1) return {
3780
+ ...item,
3781
+ position,
3782
+ rank: 2
3783
+ };
3835
3784
  return {
3785
+ ...item,
3786
+ position: boundary,
3787
+ rank: boundary === 0 ? 0 : 1
3788
+ };
3789
+ }
3790
+ function createState(items, input) {
3791
+ const handler = getHandler(input);
3792
+ const options = createOptions(input);
3793
+ const sorter = getComparator(input);
3794
+ const state = {
3795
+ compare: {
3796
+ item: compareItem(sorter),
3797
+ ranked: compareRanked(sorter)
3798
+ },
3836
3799
  handler,
3837
- items: items.slice(),
3800
+ items: { original: [] },
3838
3801
  limit: options.limit,
3839
- strings: items.map(handler),
3840
3802
  tolerance: options.tolerance
3841
3803
  };
3804
+ createItems(state, items);
3805
+ return state;
3806
+ }
3807
+ function createString(original) {
3808
+ return {
3809
+ original,
3810
+ lowercase: lowerCase(original)
3811
+ };
3842
3812
  }
3843
3813
  function fuzzy(items, configuration) {
3844
3814
  if (!Array.isArray(items)) throw new TypeError(FUZZY_MESSAGE_ARRAY);
3845
- return new Fuzzy(createFuzzyState(items, configuration));
3815
+ return new Fuzzy(createState(items, configuration));
3846
3816
  }
3847
3817
  /**
3848
3818
  * Does the needle match the haystack in a fuzzy way?
@@ -3856,53 +3826,117 @@ function fuzzy(items, configuration) {
3856
3826
  function fuzzyMatch(haystack, needle) {
3857
3827
  if (typeof haystack !== "string" || typeof needle !== "string") return false;
3858
3828
  const trimmed = needle.trim();
3859
- if (includes(haystack, trimmed, true)) return true;
3860
- return getScore(haystack, trimmed) > -1;
3829
+ const lowercaseHaystack = lowerCase(haystack);
3830
+ const lowercaseNeedle = lowerCase(trimmed);
3831
+ if (lowercaseHaystack.includes(lowercaseNeedle)) return true;
3832
+ return getScore({
3833
+ lowercase: lowercaseHaystack,
3834
+ original: haystack
3835
+ }, lowercaseNeedle) > -1;
3836
+ }
3837
+ function getComparator(input) {
3838
+ if (isPlainObject(input) && typeof input.compare === "function") return input.compare;
3839
+ defaultSorter ??= Intl.Collator().compare;
3840
+ return defaultSorter;
3861
3841
  }
3862
3842
  function getHandler(input) {
3863
3843
  if (input == null || input === getString) return getString;
3864
3844
  switch (typeof input) {
3865
- case "function": return input;
3866
- case "string": return (item) => item[input];
3845
+ case "function": return (item) => getString(input(item));
3846
+ case "number":
3847
+ case "string":
3848
+ case "symbol": return (item) => getString(item?.[input]);
3867
3849
  default:
3868
3850
  if (isPlainObject(input)) return getHandler(input.key ?? input.handler);
3869
3851
  throw new TypeError(FUZZY_MESSAGE_HANDLER);
3870
3852
  }
3871
3853
  }
3872
- function getFuzzyItems() {
3873
- return this[FUZZY_SYMBOL].items.slice();
3874
- }
3875
- function getFuzzyStrings() {
3876
- return this[FUZZY_SYMBOL].strings.slice();
3854
+ function getItems() {
3855
+ return this[FUZZY_SYMBOL].items.original.map((item) => item.data);
3877
3856
  }
3878
- function getScore(haystack, needle) {
3879
- if (!isSubsequence(haystack, needle)) return -1;
3880
- const lowerCaseHaystack = lowerCase(haystack);
3881
- const lowerCaseNeedle = lowerCase(needle);
3882
- const needleLength = lowerCaseNeedle.length;
3857
+ function getMatchEnd(haystack, needle, cursor) {
3858
+ const haystackLength = haystack.length;
3859
+ const needleLength = needle.length;
3883
3860
  let needleIndex = 0;
3884
- let previousMatchIndex = -1;
3885
- let score = 0;
3886
- for (let haystackIndex = 0; haystackIndex < lowerCaseHaystack.length; haystackIndex += 1) {
3887
- if (lowerCaseHaystack[haystackIndex] === lowerCaseNeedle[needleIndex]) {
3888
- score += 1;
3889
- if (haystackIndex === 0) score += 1;
3890
- if (previousMatchIndex !== -1) {
3891
- const gap = haystackIndex - previousMatchIndex - 1;
3892
- score += max([0, 5 - gap]);
3861
+ for (let index = cursor; index < haystackLength; index += 1) if (haystack[index] === needle[needleIndex]) {
3862
+ needleIndex += 1;
3863
+ if (needleIndex === needleLength) return index;
3864
+ }
3865
+ return -1;
3866
+ }
3867
+ function getResult(state, exact, included, scored, options) {
3868
+ const result = {
3869
+ exact: [],
3870
+ included: [],
3871
+ similar: []
3872
+ };
3873
+ exact.sort(state.compare.item);
3874
+ result.exact = exact.slice(0, options.limit).map((item) => item.data);
3875
+ included.sort(state.compare.ranked);
3876
+ result.included = included.slice(0, options.limit - result.exact.length).map((item) => item.data);
3877
+ const remaining = options.limit - result.exact.length - result.included.length;
3878
+ if (remaining === 0) return result;
3879
+ const keys = Object.keys(scored).map(Number).sort((first, second) => second - first);
3880
+ const keysLength = keys.length;
3881
+ if (keysLength === 0) return result;
3882
+ const maxScore = keys[0];
3883
+ outer: for (let index = 0; index < keysLength; index += 1) {
3884
+ const key = keys[index];
3885
+ if (maxScore - key > options.tolerance) break;
3886
+ const keyed = scored[key];
3887
+ const keyedLength = keyed.length;
3888
+ keyed.sort(state.compare.item);
3889
+ for (let keyedIndex = 0; keyedIndex < keyedLength; keyedIndex += 1) {
3890
+ result.similar.push(keyed[keyedIndex].data);
3891
+ if (result.similar.length >= remaining) break outer;
3892
+ }
3893
+ }
3894
+ return result;
3895
+ }
3896
+ function getScore(item, needle) {
3897
+ const haystackLength = item.lowercase.length;
3898
+ const needleLength = needle.length;
3899
+ if (needleLength > haystackLength) return -1;
3900
+ let best = -1;
3901
+ let cursor = 0;
3902
+ while (cursor < haystackLength) {
3903
+ const end = getMatchEnd(item.lowercase, needle, cursor);
3904
+ if (end === -1) break;
3905
+ let index = end;
3906
+ let needleIndex = needleLength - 1;
3907
+ let next = -1;
3908
+ let score = 0;
3909
+ let start = end;
3910
+ while (needleIndex >= 0) {
3911
+ if (item.lowercase[index] === needle[needleIndex]) {
3912
+ score += 1;
3913
+ if (next !== -1) score += max([0, 5 - (next - index - 1)]);
3914
+ if (isBoundary(item, index)) score += 2;
3915
+ next = index;
3916
+ start = index;
3917
+ needleIndex -= 1;
3893
3918
  }
3894
- previousMatchIndex = haystackIndex;
3895
- needleIndex += 1;
3919
+ index -= 1;
3896
3920
  }
3897
- if (needleIndex === needleLength) break;
3921
+ if (start === 0) score += 1;
3922
+ if (score > best) best = score;
3923
+ cursor = start + 1;
3898
3924
  }
3899
- score -= floor(lowerCaseHaystack.length / 3);
3900
- return max([0, score]);
3925
+ if (best === -1) return -1;
3926
+ return max([0, best - floor(haystackLength / 3)]);
3927
+ }
3928
+ function getStrings() {
3929
+ return this[FUZZY_SYMBOL].items.original.map((item) => item.string.original);
3901
3930
  }
3902
3931
  function getTolerance(input, state) {
3903
3932
  if (typeof input === "number" && !Number.isNaN(input)) return input < 0 ? 0 : floor(input);
3904
3933
  return state?.tolerance ?? 5;
3905
3934
  }
3935
+ function isBoundary(string, index) {
3936
+ if (index === 0 || FUZZY_BOUNDARY_SEPARATOR.test(string.lowercase[index - 1])) return true;
3937
+ if (string.original.length !== string.lowercase.length) return false;
3938
+ return string.original[index] !== string.lowercase[index] && string.original[index - 1] === string.lowercase[index - 1];
3939
+ }
3906
3940
  /**
3907
3941
  * Is the value a fuzzy searcher?
3908
3942
  *
@@ -3914,74 +3948,48 @@ function getTolerance(input, state) {
3914
3948
  function isFuzzy(value) {
3915
3949
  return typeof value === "object" && value !== null && "$fuzzy" in value && value["$fuzzy"] === true;
3916
3950
  }
3917
- function isSubsequence(haystack, needle) {
3918
- const lowerCaseHaystack = lowerCase(haystack);
3919
- const lowerCaseNeedle = lowerCase(needle);
3920
- const haystackLength = lowerCaseHaystack.length;
3921
- const needleLength = lowerCaseNeedle.length;
3922
- let needleIndex = 0;
3923
- for (let haystackIndex = 0; haystackIndex < haystackLength; haystackIndex += 1) {
3924
- if (lowerCaseHaystack[haystackIndex] === lowerCaseNeedle[needleIndex]) needleIndex += 1;
3925
- if (needleIndex === needleLength) return true;
3926
- }
3927
- return false;
3928
- }
3929
3951
  function search(input, option) {
3930
3952
  const state = this[FUZZY_SYMBOL];
3931
- const options = option == null ? state : createFuzzyOptions(option, state);
3932
- const result = {
3933
- exact: [],
3934
- similar: []
3935
- };
3953
+ const options = option == null ? state : createOptions(option, state);
3936
3954
  const value = typeof input === "string" ? input.trim() : "";
3955
+ const lowercaseValue = lowerCase(value);
3937
3956
  if (value.length === 0) {
3938
- result.exact = state.items.slice(0, options.limit);
3939
- return result;
3957
+ state.items.sorted ??= state.items.original.toSorted(state.compare.item);
3958
+ return {
3959
+ exact: state.items.sorted.slice(0, options.limit).map((item) => item.data),
3960
+ included: [],
3961
+ similar: []
3962
+ };
3940
3963
  }
3941
- let { length } = state.items;
3942
3964
  const exact = [];
3943
- const similar = [];
3965
+ const included = [];
3944
3966
  const scored = {};
3967
+ const { length } = state.items.original;
3945
3968
  for (let index = 0; index < length; index += 1) {
3946
- const item = state.items[index];
3947
- const haystack = state.strings[index];
3948
- if (includes(haystack, value, true)) {
3949
- exact.push({
3950
- item,
3951
- haystack
3952
- });
3969
+ const item = state.items.original[index];
3970
+ const { string } = item;
3971
+ if (string.lowercase === lowercaseValue) {
3972
+ exact.push(item);
3953
3973
  continue;
3954
3974
  }
3955
- const score = getScore(haystack, value);
3975
+ const position = string.lowercase.indexOf(lowercaseValue);
3976
+ if (position > -1) {
3977
+ included.push(createRanked(item, lowercaseValue, position));
3978
+ continue;
3979
+ }
3980
+ const score = getScore(string, lowercaseValue);
3956
3981
  if (score > -1) {
3957
3982
  scored[score] ??= [];
3958
- scored[score].push({
3959
- item,
3960
- haystack
3961
- });
3962
- }
3963
- }
3964
- const keys = Object.keys(scored).map(Number).sort((first, second) => second - first);
3965
- length = keys.length;
3966
- if (length > 0 && options.tolerance > 0) {
3967
- const maxScore = keys[0];
3968
- for (let index = 0; index < length; index += 1) {
3969
- const key = keys[index];
3970
- if (maxScore - key > options.tolerance) break;
3971
- similar.push(...createFuzzyItems(scored[key]));
3983
+ scored[score].push(item);
3972
3984
  }
3973
3985
  }
3974
- result.exact = createFuzzyItems(options.limit == null ? exact : exact.slice(0, options.limit));
3975
- if (options.limit == null) result.similar = similar;
3976
- else result.similar = similar.slice(0, options.limit - result.exact.length);
3977
- return result;
3986
+ return getResult(state, exact, included, scored, options);
3978
3987
  }
3979
- function setFuzzyItems(value) {
3988
+ function setItems(value) {
3980
3989
  if (!Array.isArray(value)) throw new TypeError(FUZZY_MESSAGE_ARRAY);
3981
- const state = this[FUZZY_SYMBOL];
3982
- state.items = value.slice();
3983
- state.strings = value.map((item) => state.handler(item));
3990
+ createItems(this[FUZZY_SYMBOL], value);
3984
3991
  }
3992
+ let defaultSorter;
3985
3993
  fuzzy.is = isFuzzy;
3986
3994
  fuzzy.match = fuzzyMatch;
3987
3995
  //#endregion
@@ -4089,6 +4097,74 @@ function truncate(value, length, suffix) {
4089
4097
  return `${value.slice(0, truncatedLength)}${actualSuffix}`;
4090
4098
  }
4091
4099
  //#endregion
4100
+ //#region src/string/match.ts
4101
+ /**
4102
+ * Check if a string ends with a specified substring
4103
+ *
4104
+ * @param haystack String to look in
4105
+ * @param needle String to look for
4106
+ * @param ignoreCase Ignore case when matching? _(defaults to `false`)_
4107
+ * @returns `true` if the string ends with the given substring, otherwise `false`
4108
+ *
4109
+ * @example
4110
+ * ```typescript
4111
+ * endsWith('this is a test', 'test'); // => true
4112
+ * endsWith('this is a test', 'Test'); // => false
4113
+ * endsWith('this is a test', 'Test', true); // => true
4114
+ * endsWith('this is a test', 'not'); // => false
4115
+ * ```
4116
+ */
4117
+ function endsWith(haystack, needle, ignoreCase) {
4118
+ return match(STRING_MATCH_ENDS_WITH, haystack, needle, ignoreCase === true);
4119
+ }
4120
+ /**
4121
+ * Check if a string includes a specified substring
4122
+ *
4123
+ * @param haystack String to look in
4124
+ * @param needle String to look for
4125
+ * @param ignoreCase Ignore case when matching? _(defaults to `false`)_
4126
+ * @returns `true` if the string includes the given substring, otherwise `false`
4127
+ *
4128
+ * @example
4129
+ * ```typescript
4130
+ * includes('this is a test', 'is'); // => true
4131
+ * includes('this is a test', 'Is'); // => false
4132
+ * includes('this is a test', 'Is', true); // => true
4133
+ * includes('this is a test', 'not'); // => false
4134
+ * ```
4135
+ */
4136
+ function includes(haystack, needle, ignoreCase) {
4137
+ return match(STRING_MATCH_INCLUDES, haystack, needle, ignoreCase === true);
4138
+ }
4139
+ function match(type, haystack, needle, ignoreCase) {
4140
+ if (typeof haystack !== "string" || typeof needle !== "string") return false;
4141
+ matchMemoizers[type] ??= memoize(matchCallback.bind(type));
4142
+ return matchMemoizers[type].run(haystack, needle, ignoreCase);
4143
+ }
4144
+ function matchCallback(haystack, needle, ignoreCase) {
4145
+ return (ignoreCase ? haystack.toLocaleLowerCase() : haystack)[this](ignoreCase ? needle.toLocaleLowerCase() : needle);
4146
+ }
4147
+ /**
4148
+ * Check if a string starts with a specified substring
4149
+ *
4150
+ * @param haystack String to look in
4151
+ * @param needle String to look for
4152
+ * @param ignoreCase Ignore case when matching? _(defaults to `false`)_
4153
+ * @returns `true` if the string starts with the given substring, otherwise `false`
4154
+ *
4155
+ * @example
4156
+ * ```typescript
4157
+ * startsWith('this is a test', 'this'); // => true
4158
+ * startsWith('this is a test', 'This'); // => false
4159
+ * startsWith('this is a test', 'This', true); // => true
4160
+ * startsWith('this is a test', 'not'); // => false
4161
+ * ```
4162
+ */
4163
+ function startsWith(haystack, needle, ignoreCase) {
4164
+ return match(STRING_MATCH_STARTS_WITH, haystack, needle, ignoreCase === true);
4165
+ }
4166
+ const matchMemoizers = {};
4167
+ //#endregion
4092
4168
  //#region src/models/string/string.normalize.model.ts
4093
4169
  const NORMALIZE_DEBURR_CHARACTERS = {
4094
4170
  Æ: "AE",
@@ -27,26 +27,28 @@ export type Fuzzy<Item> = {
27
27
  *
28
28
  * @param value Value to search for
29
29
  * @param options Search options
30
- * @returns Search results, with exact matches _(ordered)_ and similar matches _(ordered by relevance)_
30
+ * @returns Search results, with exact, included, and similar matches
31
31
  */
32
32
  search(value: string, options?: FuzzyOptions): FuzzyResult<Item>;
33
33
  /**
34
34
  * Search for items matching the given value
35
35
  *
36
36
  * @param value Value to search for
37
- * @param limit Maximum number of combined items to return in `exact` and `similar`
38
- * @returns Search results, with exact matches _(ordered)_ and similar matches _(ordered by relevance)_
37
+ * @param limit Maximum number of combined items to return in `exact`, `included`, and `similar`
38
+ * @returns Search results, with exact, included, and similar matches
39
39
  */
40
40
  search(value: string, limit: number): FuzzyResult<Item>;
41
41
  };
42
42
  export type FuzzyConfiguration<Item> = {
43
+ handler?: (item: Item) => string;
43
44
  /**
44
- * Handler to stringify items
45
+ * Function to compare the stringified items for results _(defaults to `Intl.Collator().compare`)_
45
46
  *
46
- * - May be a function that takes an item and returns a string, or if items are plain objects, a key of the item to use to grab a string value from
47
- * - Defaults to `getString`
47
+ * @param first First string to compare
48
+ * @param second Second string to compare
49
+ * @returns Comparison result, similar to `Array.prototype.sort` callback
48
50
  */
49
- handler?: (item: Item) => string;
51
+ compare?: (first: string, second: string) => number;
50
52
  } & (Item extends PlainObject ? {
51
53
  /**
52
54
  * Key to use to stringify items
@@ -55,13 +57,13 @@ export type FuzzyConfiguration<Item> = {
55
57
  */
56
58
  key?: keyof Item;
57
59
  } : {}) & FuzzyOptions;
58
- export type FuzzyItem<Item> = {
59
- item: Item;
60
- haystack: string;
60
+ export type FuzzyItem<Data> = {
61
+ data: Data;
62
+ string: FuzzyString;
61
63
  };
62
64
  export type FuzzyOptions = {
63
65
  /**
64
- * Maximum number of combined items to return in `exact` and `similar` _(defaults to all matches)_
66
+ * Maximum number of combined items to return in `exact`, `included`, and `similar` _(defaults to all matches)_
65
67
  */
66
68
  limit?: number;
67
69
  /**
@@ -72,16 +74,27 @@ export type FuzzyOptions = {
72
74
  */
73
75
  tolerance?: number;
74
76
  };
77
+ export type FuzzyRanked<Data> = {
78
+ position: number;
79
+ /**
80
+ * `0` for starting with the value, `1` for the value starting a word, `2` for anything else
81
+ */
82
+ rank: number;
83
+ } & FuzzyItem<Data>;
75
84
  /**
76
- * Search results from a fuzzy search, with exact and similar matches
85
+ * Search results from a fuzzy search, with exact, included, and similar matches
77
86
  */
78
87
  export type FuzzyResult<Item> = {
79
88
  /**
80
- * Ordered items that exactly match the search value
89
+ * Items whose strings exactly match the search value, ordered alphabetically
81
90
  */
82
91
  exact: Item[];
83
92
  /**
84
- * Ordered items that are similar to the search value, ranked by relevance
93
+ * Items whose strings contain the search value, ordered alphabetically
94
+ */
95
+ included: Item[];
96
+ /**
97
+ * Items whose strings are similar to the search value, ranked by relevance, sorted by score, and ordered alphabetically within the same score
85
98
  */
86
99
  similar: Item[];
87
100
  };
@@ -90,19 +103,34 @@ export type FuzzyResult<Item> = {
90
103
  */
91
104
  export type FuzzySearchOptions = { [Key in keyof FuzzyOptions]: FuzzyOptions[Key]; };
92
105
  export type FuzzyState<Item> = {
106
+ compare: FuzzyStateCompare<Item>;
93
107
  handler(item: Item): string;
94
- items: Item[];
95
- limit?: number;
96
- strings: string[];
108
+ items: FuzzyStateItems<Item>;
109
+ limit: number;
97
110
  tolerance: number;
98
111
  };
112
+ type FuzzyStateCompare<Item = unknown> = {
113
+ item: (first: FuzzyItem<Item>, second: FuzzyItem<Item>) => number;
114
+ ranked: (first: FuzzyRanked<Item>, second: FuzzyRanked<Item>) => number;
115
+ };
116
+ type FuzzyStateItems<Item> = {
117
+ original: Array<FuzzyItem<Item>>;
118
+ sorted?: Array<FuzzyItem<Item>>;
119
+ };
120
+ export type FuzzyString = {
121
+ lowercase: string;
122
+ original: string;
123
+ };
99
124
  export type InternalFuzzy<Item = unknown> = {
100
125
  [FUZZY_SYMBOL]: FuzzyState<Item>;
101
126
  } & Fuzzy<Item>;
127
+ export declare const FUZZY_BOUNDARY_BONUS = 2;
128
+ export declare const FUZZY_BOUNDARY_SEPARATOR: RegExp;
102
129
  export declare const FUZZY_LENGTH_DIVISOR = 3;
103
130
  export declare const FUZZY_MESSAGE_ARRAY = "Fuzzy requires an array of items";
104
131
  export declare const FUZZY_MESSAGE_HANDLER = "Fuzzy requires a key or function to stringify items";
105
132
  export declare const FUZZY_PROPERTY = "$fuzzy";
106
133
  export declare const FUZZY_PROXIMITY_THRESHOLD = 5;
107
134
  export declare const FUZZY_SYMBOL: unique symbol;
108
- //#endregion
135
+ //#endregion
136
+ export {};
@@ -1,4 +1,6 @@
1
1
  //#region src/models/string/string.fuzzy.model.ts
2
+ const FUZZY_BOUNDARY_BONUS = 2;
3
+ const FUZZY_BOUNDARY_SEPARATOR = /[\s./:\\_-]/;
2
4
  const FUZZY_LENGTH_DIVISOR = 3;
3
5
  const FUZZY_MESSAGE_ARRAY = "Fuzzy requires an array of items";
4
6
  const FUZZY_MESSAGE_HANDLER = "Fuzzy requires a key or function to stringify items";
@@ -6,4 +8,4 @@ const FUZZY_PROPERTY = "$fuzzy";
6
8
  const FUZZY_PROXIMITY_THRESHOLD = 5;
7
9
  const FUZZY_SYMBOL = Symbol(FUZZY_PROPERTY);
8
10
  //#endregion
9
- export { FUZZY_LENGTH_DIVISOR, FUZZY_MESSAGE_ARRAY, FUZZY_MESSAGE_HANDLER, FUZZY_PROPERTY, FUZZY_PROXIMITY_THRESHOLD, FUZZY_SYMBOL };
11
+ export { FUZZY_BOUNDARY_BONUS, FUZZY_BOUNDARY_SEPARATOR, FUZZY_LENGTH_DIVISOR, FUZZY_MESSAGE_ARRAY, FUZZY_MESSAGE_HANDLER, FUZZY_PROPERTY, FUZZY_PROXIMITY_THRESHOLD, FUZZY_SYMBOL };
@@ -1,7 +1,7 @@
1
1
  import { PlainObject } from "../models/index.mjs";
2
- import { Fuzzy, FuzzyConfiguration, FuzzyItem, FuzzyOptions, FuzzyResult, FuzzyState } from "../models/string/string.fuzzy.model.mjs";
2
+ import { Fuzzy, FuzzyConfiguration, FuzzyOptions, FuzzyResult, FuzzyState, InternalFuzzy } from "../models/string/string.fuzzy.model.mjs";
3
3
  //#region src/string/fuzzy.d.ts
4
- declare function Fuzzy(this: any, state: FuzzyState<unknown>): void;
4
+ declare function Fuzzy(this: InternalFuzzy, state: FuzzyState<unknown>): void;
5
5
  /**
6
6
  * Create a fuzzy searcher for an array of items
7
7
  *
@@ -50,4 +50,4 @@ export declare namespace fuzzy {
50
50
  export var match: typeof fuzzyMatch;
51
51
  }
52
52
  //#endregion
53
- export type { Fuzzy, FuzzyItem, FuzzyOptions, FuzzyResult };
53
+ export type { Fuzzy, FuzzyOptions, FuzzyResult };
@@ -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.220.0",
4
4
  "description": "Atomic utilities for making your JavaScript better.",
5
5
  "keywords": [
6
6
  "array",