Skip to content

Instantly share code, notes, and snippets.

@tempelmann
Last active February 1, 2024 17:31
Show Gist options
  • Save tempelmann/0721dc3bc7b39b1eeb7095e2baf34516 to your computer and use it in GitHub Desktop.
Save tempelmann/0721dc3bc7b39b1eeb7095e2baf34516 to your computer and use it in GitHub Desktop.
Case or diacritics insensitive NSString subclass for convenient use in NSDictionary, NSSet etc. as KeyType
//
// NSStringFolded.h
//
// Created by Thomas Tempelmann on 31 Jan 2024.
// Author retains no copyright - given to public domain
//
// Purpose: Provide a way to have case insensitive keys in an NSDictionary
// (see https://stackoverflow.com/a/77914964/43615)
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface NSStringFolded : NSString
extern NSExceptionName const NSStringFoldedIncompatibleTypeException; // thrown if a comparison is made with a plain NSString, which would give unreliable results
// Inits a string with custom folding options, e.g. to make é equal to e by passing NSDiacriticInsensitiveSearch as foldingOptions.
- (instancetype) initWithString:(NSString *)aString foldingOptions:(NSStringCompareOptions)foldingOptions;
- (instancetype) initWithString:(NSString *)aString foldingOptions:(NSStringCompareOptions)foldingOptions locale:(nullable NSLocale *)locale;
@end
@interface NSString (FoldedExtension)
// Create from NSStrings
- (NSStringFolded*) caseInsensitiveCopy;
- (NSStringFolded*) copyWithFoldingOptions:(NSStringCompareOptions)foldingOptions;
- (NSStringFolded*) copyWithFoldingOptions:(NSStringCompareOptions)foldingOptions locale:(nullable NSLocale *)locale;
@end
NS_ASSUME_NONNULL_END
//
// NSStringFolded.m
//
// Created by Thomas Tempelmann on 31 Jan 2024.
// Updated 1 Feb 2024 (added `compare:` and type compatibility check)
//
// Author retains no copyright - given to public domain
//
// Origin: https://gist.github.com/tempelmann/0721dc3bc7b39b1eeb7095e2baf34516
//
#import "NSStringFolded.h"
#define THROW_ON_INCOMPATIBLE_COMPARE (DEBUG && 1) // change 0 to 1 if you want to be alerted of mixing with NSString during development
@interface NSStringFolded()
@property (nonatomic) NSString *original; // this is the "main" string
@property (nonatomic) NSString *folded; // this is the case insensitive copy
@property (nonatomic) NSStringCompareOptions foldingOptions;
@property (nonatomic) NSLocale *locale;
@end
@implementation NSStringFolded
NSExceptionName const NSStringFoldedIncompatibleTypeException = @"NSStringFoldedIncompatibleTypeException";
//
// Minimum of methods for subclassing the NSString class cluster
// (see https://stackoverflow.com/a/21331422/43615 and https://www.mikeash.com/pyblog/friday-qa-2010-03-12-subclassing-class-clusters.html)
//
// Note: Archiving (via NSCoder) does currently not (re)store the folding options and the locale - if you need this, add the NSCoding methods yourself here.
//
- (instancetype) initWithCharactersNoCopy:(unichar *)characters length:(NSUInteger)length freeWhenDone:(BOOL)freeBuffer {
self = [super init];
if (self) {
if (self.foldingOptions == 0) { // let's set some default if folding is not set
self.foldingOptions = NSCaseInsensitiveSearch; // feel free to add NSWidthInsensitiveSearch or NSDiacriticInsensitiveSearch or NSNumericSearch
}
self.original = [[NSString alloc] initWithCharactersNoCopy:characters length:length freeWhenDone:freeBuffer];
self.folded = [self.original stringByFoldingWithOptions:self.foldingOptions locale:self.locale];
}
return self;
}
- (NSUInteger) length {
return self.original.length;
}
- (unichar) characterAtIndex:(NSUInteger)index {
return [self.original characterAtIndex:index];
}
//
// Optional overrides for performance (see NSString.h)
//
- (void)getCharacters:(unichar *)buffer range:(NSRange)range {
[self.original getCharacters:buffer range:range];
}
//
// Custom initializers
//
- (instancetype) initWithString:(NSString *)aString foldingOptions:(NSStringCompareOptions)foldingOptions locale:(nullable NSLocale *)locale {
self.foldingOptions = foldingOptions;
self.locale = locale;
return [super initWithString:aString];
}
- (instancetype) initWithString:(NSString *)aString foldingOptions:(NSStringCompareOptions)foldingOptions {
self.foldingOptions = foldingOptions;
self.locale = nil;
return [super initWithString:aString];
}
//
// Custom overrides for case insensitive handling
//
- (instancetype) copyWithZone:(NSZone *)zone {
NSStringFolded *copy = [NSStringFolded.alloc init];
if (copy) {
copy.original = self.original;
copy.folded = self.folded;
copy.foldingOptions = self.foldingOptions;
copy.locale = copy.locale;
}
return copy;
}
- (BOOL) isEqualToString:(NSStringFolded*)object {
if ([object isKindOfClass:self.class]) {
return [self.folded isEqualToString:object.folded];
} else {
#if THROW_ON_INCOMPATIBLE_COMPARE
// This should be considered a programming error, as comparing plain NSString with folded strings makes no sense.
@throw [NSException exceptionWithName:NSStringFoldedIncompatibleTypeException reason:[NSString stringWithFormat:@"Compared value has incompatible type (%@)", object.className] userInfo:@{@"objects": @[self, object]}];
#else
// Give at least some result instead of crashing - let's just compare the unfolded strings
return [self.original isEqualToString:object];
#endif
}
}
- (NSComparisonResult) compare:(NSStringFolded*)object {
if ([object isKindOfClass:self.class]) {
return [self.folded compare:object.folded];
} else {
#if THROW_ON_INCOMPATIBLE_COMPARE
// This should be considered a programming error, as comparing plain NSString with folded strings makes no sense.
@throw [NSException exceptionWithName:NSStringFoldedIncompatibleTypeException reason:[NSString stringWithFormat:@"Compared value has incompatible type (%@)", object.className] userInfo:@{@"objects": @[self, object]}];
#else
// Give at least some result instead of crashing - let's just compare the unfolded strings
return [self.original compare:object];
#endif
}
}
- (NSUInteger) hash {
NSUInteger h = self.folded.hash;
return h;
}
#if DEBUG
static NSString* CI (NSString *s) { // convenience function for creating strings of this class
return s.caseInsensitiveCopy;
}
+ (void) initialize {
// Let's do some quick'n dirty self tests here
NSString *s1 = [NSStringFolded.alloc initWithString:@"ABC"]; // one way to create one
NSString *s2 = [@"abc" caseInsensitiveCopy]; // another way to create one
NSAssert ( [s1 isEqual:s2], @"'ABC'=='abc'");
NSAssert ( [s2 isEqual:s1], @"'abc'=='ABC'");
NSAssert ( [s1 isEqualTo:s2], @"'ABC'=='abc'");
NSAssert ( [s2 isEqualTo:s1], @"'abc'=='ABC'");
NSAssert ( [s1 isEqualToString:s2], @"'ABC'=='abc'");
NSAssert ( [s2 isEqualToString:s1], @"'abc'=='ABC'");
NSAssert (![s1 isEqualToString:@"abc"], @"'ABC'=='abc'");
NSAssert ( [s1 isEqualToString:@"ABC"], @"'ABC'=='abc'");
s1 = [NSStringFolded.alloc initWithString:@"é" foldingOptions:NSDiacriticInsensitiveSearch locale:nil];
s2 = [NSStringFolded.alloc initWithString:@"e" foldingOptions:NSDiacriticInsensitiveSearch locale:nil];
NSAssert ( [s1 isEqual:s2], @"'é'=='e'");
// case-insensitive NSDictionary keys
NSDictionary *d1 = @{ @"a":@1, @"A":@2 };
NSDictionary *d2 = @{ CI(@"a"):@1, CI(@"A"):@2 };
NSAssert (d1.count == 2, @"d1"); // here both "a" and "A" are stored in the dictionary
NSAssert (d2.count == 1, @"d2"); // here, since "a" and "A" are considered equal keys, we end up with one item in the dict
// sorting
NSArray<NSString*> *stringArray = [@[@"a",@"B",@"c",@"D"] sortedArrayUsingSelector:@selector(compare:)];
NSArray *expected = @[@"B",@"D",@"a",@"c"];
NSAssert ([stringArray isEqualToArray:expected], @"");
NSArray<NSString*> *ciStringArray = [@[CI(@"a"),CI(@"B"),CI(@"c"),CI(@"D")] sortedArrayUsingSelector:@selector(compare:)];
expected = @[@"a",@"B",@"c",@"D"];
NSAssert ([ciStringArray isEqualToArray:expected], @"");
}
#endif
@end
@implementation NSString (FoldedExtension)
- (NSStringFolded*) caseInsensitiveCopy {
return [self copyWithFoldingOptions:NSCaseInsensitiveSearch locale:nil];
}
- (NSStringFolded*) copyWithFoldingOptions:(NSStringCompareOptions)foldingOptions {
return [self copyWithFoldingOptions:foldingOptions locale:nil];
}
- (NSStringFolded*) copyWithFoldingOptions:(NSStringCompareOptions)foldingOptions locale:(nullable NSLocale *)locale {
return [NSStringFolded.alloc initWithString:self foldingOptions:foldingOptions locale:locale];
}
@end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment