Last active
February 1, 2024 17:31
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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