We should always be thinking about how to write our code in a generic way. Otherwise, what happens if we want something very similar to what we've just written, but slightly different in some minor way? Well, it usually results in a lot of copy & pasting. Let's write some code that will allow us to apply the rules you want to apply, but in a more generic way.
I want to start with a skeleton that looks something like this:
struct StringFormatter {
enum CaseRule {
case None, UppercaseOnly, ConvertToUpper, LowercaseOnly, ConvertToLower
}
enum AsciiRule {
case None, AsciiOnly
}
var blacklistCharacters = Set<Character>()
var replacementCharacter = ""
var caseRule = CaseRule.None
var asciiRule = AsciiRule.None
func stringByApplyingFormatting(toString string: String) -> String {
// TODO: Implement actual formatting logic
return ""
}
}
Now we've got a reusable structure for applying this sort of formatting to our strings. Importantly, despite there being no logic in it yet, we have implemented the method we'd be calling to apply the formatting, so we can stick with test-driven development and go ahead and write our unit test.
class StringTestStuff: XCTestCase {
func testStringFormatting() {
let input = "Is my résumé good enough?"
let expectedResult = "is-my-r-sum-good-enough-"
var stringFormatter = StringFormatter()
stringFormatter.blacklistCharacters = Set(" -&+$,/:;=?@\"#{}|^~[]`\\*()%.!'".characters)
stringFormatter.replacementCharacter = "-"
stringFormatter.caseRule = .ConvertToLower
stringFormatter.asciiRule = .AsciiOnly
let formattedString = stringFormatter.stringByApplyingFormatting(toString: input)
XCTAssertEqual(formattedString, expectedResult)
}
}
We can run the test and see that's it's failing (confirming that the test works):

Of course, much like my previous answer, this test is far from complete. All this test is doing, though, is simply testing the one known acceptance criteria you've provided. Realistically, we'd want quite a few tests for this, and we'd want to test several of the different aspects of the StringFormatter
individually (applying just the case rule, applying just the ASCII rule, etc), and several variations of different combinations. We'd also probably want to see some performance tests for the StringFormatter
too. There are going to be multiple ways to accomplish this, and we want to make sure that when we refactor, we've not only not broken things, but we've also not made things run super slow.
But let's not kid ourselves, it would be impossible to ever test this code nearly as completely as the simply isAscii
test manages. We will simply have to pick a set of smart tests and be prepared to add tests as edge cases appear.
So with all this in mind, let's work on making this test pass. (I know your code would pass the test, but let's see if we can do it without import Foundation
).
In order to keep in line with the single responsibility principle, we need to add a couple of helper functions.
First, this function will apply our caseRule
property:
private func stringWithCaseConversionApplied(string: String) -> String {
switch caseRule {
case .ConvertToLower: return string.lowercaseString
case .ConvertToUpper: return string.uppercaseString
default: return string
}
}
And if our caseRule
property is set to an "only" value rather than a "convert" value, this method will determine if any individual character would pass that check:
private func caseFilter(character: Character) -> Bool {
if caseRule == .LowercaseOnly && String(character).uppercaseString == String(character) {
return false
}
if caseRule == .UppercaseOnly && String(character).lowercaseString == String(character) {
return false
}
return true
}
But we've implemented another method to apply all of the filtering rules:
private func ruleFilter(character: Character) -> Bool {
if blacklistCharacters.contains(character) {
return false
}
if asciiRule == .AsciiOnly && !character.isAscii {
return false
}
return caseFilter(character)
}
So, now we have all the pieces in place to check individual characters. It's time to break out some of the Swift standard library functions that will allow us to avoid importing Foundation
.
We're back to working in stringByApplyingFormatting()
, the method around which we've written our unit test.
We're going to first apply the case conversion rule by calling the method we wrote to do that, then we're going to filter out any illegal characters based on our ruleFilter
method. This will break our string down into an array of character sequences which can be joined back together with our replacementCharacter
. This will eliminate the trouble of repeatedly checking for occurrences of doubles of the replacement character.
The last step looks a little weird. We need to check if the first or last character was an illegal character, and if so, stick the replacement character at the beginning or end (or both) because the join method will only place it between our sub sequences. It won't keep track of whether or not it eliminated the first or last characters.
So, the logic within our stringByApplyingFormatting()
method now looks like this:
func stringByApplyingFormatting(toString string: String) -> String {
// Apply case conversion rule
let caseValue = stringWithCaseConversionApplied(string)
// Apply the rule filter to each character
// This splits the string into an array of character subsequences
let splitCharacters = caseValue.characters.split { !ruleFilter($0) }
// Map the array of character subsequences to an array of strings that can be joined
let splitString = splitCharacters.map(String.init)
// Join the array of strings with the specified replacement character
let joinedString = splitString.joinWithSeparator(replacementCharacter)
// Check if the original string started or ended with an illegal character
// If so, prepend/append the replacement character
let finalString =
(!ruleFilter(caseValue.characters.first ?? Character("")) ? replacementCharacter : "") +
joinedString +
(!ruleFilter(caseValue.characters.last ?? Character("")) ? replacementCharacter : "")
return finalString
}
And with this logic, if we re-run our unit test, it's passing now. And we also no longer need to import Foundation
into our code base, so this code runs perfectly well on Linux too.
From here, the next step is adding a pretty significant suite of tests around all of the individual rules and all of the possible combinations of rules, with all sorts of test strings.
The full StringFormatter
struct:
struct StringFormatter {
enum CaseRule {
case None, UppercaseOnly, ConvertToUpper, LowercaseOnly, ConvertToLower
}
enum AsciiRule {
case None, AsciiOnly
}
var blacklistCharacters = Set<Character>()
var replacementCharacter = ""
var caseRule = CaseRule.None
var asciiRule = AsciiRule.None
func stringByApplyingFormatting(toString string: String) -> String {
let caseValue = stringWithCaseConversionApplied(string)
let splitCharacters = caseValue.characters.split { !ruleFilter($0) }
let splitString = splitCharacters.map(String.init)
let joinedString = splitString.joinWithSeparator(replacementCharacter)
let finalString = (!ruleFilter(caseValue.characters.first ?? Character("")) ? replacementCharacter : "") + joinedString + (!ruleFilter(caseValue.characters.last ?? Character("")) ? replacementCharacter : "")
return finalString
}
private func stringWithCaseConversionApplied(string: String) -> String {
switch caseRule {
case .ConvertToLower: return string.lowercaseString
case .ConvertToUpper: return string.uppercaseString
default: return string
}
}
private func ruleFilter(character: Character) -> Bool {
if blacklistCharacters.contains(character) {
return false
}
if asciiRule == .AsciiOnly && !character.isAscii {
return false
}
return caseFilter(character)
}
private func caseFilter(character: Character) -> Bool {
if caseRule == .LowercaseOnly && String(character).uppercaseString == String(character) {
return false
}
if caseRule == .UppercaseOnly && String(character).lowercaseString == String(character) {
return false
}
return true
}
}
The unit test is the same as posted above:
class StringTestStuff: XCTestCase {
func testStringFormatting() {
let input = "Is my résumé good enough?"
let expectedResult = "is-my-r-sum-good-enough-"
var stringFormatter = StringFormatter()
stringFormatter.blacklistCharacters = Set(" -&+$,/:;=?@\"#{}|^~[]`\\*()%.!'".characters)
stringFormatter.replacementCharacter = "-"
stringFormatter.caseRule = .ConvertToLower
stringFormatter.asciiRule = .AsciiOnly
let formattedString = stringFormatter.stringByApplyingFormatting(toString: input)
XCTAssertEqual(formattedString, expectedResult)
}
}
As an addendum, one thing that might be neat to add to the StringFormatter
struct would be a means for mapping certain characters to be replaced with specified characters. So, for example, you're not going to allow ASCII, but you could potentially replace è
with a "regular" e
.
So we might add a property to the struct that looks like this:
var replacementMap = [Character: Character]()
So it's a dictionary where the key is the character we want to remove and the value is the character we want to replace it with.
stringFormatter.replacementMap = [
"è" : "e",
"é" : "e",
"ê" : "e",
"ë" : "e",
"ē" : "e",
"ė" : "e",
"ę" : "e"
]
Of course, that particular map just covers the characters that might map to "e"
, and we'd have to do this before we applied the ASCII rule. But implementation would look something like this:
private func stringByApplyingReplacementMap(string: String) -> String {
return String(string.characters.map { return self.replacementMap[$0] ?? $0 })
}
For more reading on testing, check out Unit Tests Don't Prevent Bugs.
isAscii
doesn't seem like it'd do what I'd expect it to do. \$\endgroup\$