diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e3de202..dcfe02d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ _None_ * Added the `basename` and `dirname` string filters for getting a filename, or parent folder (respectively), out of a path. [David Jennes](https://github.com/djbe) [#60](https://github.com/SwiftGen/StencilSwiftKit/pull/60) +* Modify the `swiftIdentifier` string filter to accept an optional "pretty" mode, to first apply the `snakeToCamelCase` filter before converting to an identifier. + [David Jennes](https://github.com/djbe) + [#61](https://github.com/SwiftGen/StencilSwiftKit/pull/61) ### Internal Changes diff --git a/Documentation/filters-strings.md b/Documentation/filters-strings.md index 194408ff..1f54cabe 100644 --- a/Documentation/filters-strings.md +++ b/Documentation/filters-strings.md @@ -166,7 +166,9 @@ This filter accepts a parameter (boolean, default `false`) that controls the pre ## Filter: `swiftIdentifier` -Transforms an arbitrary string into a valid Swift identifier (using only valid characters for a Swift identifier as defined in the Swift language reference). It will apply the following rules: +This filter has a couple of modes that you can specifiy using an optional argument (defaults to "normal"): + +**normal**: Transforms an arbitrary string into a valid Swift identifier (using only valid characters for a Swift identifier as defined in the Swift language reference). It will apply the following rules: - Uppercase the first character. - Prefix with an underscore if the first character is a number. @@ -175,11 +177,28 @@ Transforms an arbitrary string into a valid Swift identifier (using only valid c The list of allowed characters can be found here: https://developer.apple.com/library/content/documentation/Swift/Conceptual/Swift_Programming_Language/LexicalStructure.html -| Input | Output | -|------------|------------| -| `hello` | `Hello` | -| `42hello` | `_42hello` | -| `some$URL` | `Some_URL` | +| Input | Output | +|------------------------|-------------------------| +| `hello` | `Hello` | +| `42hello` | `_42hello` | +| `some$URL` | `Some_URL` | +| `25 Ultra Light` | `_25_Ultra_Light` | +| `26_extra_ultra_light` | `_26_extra_ultra_light` | +| `apples.count` | `Apples_Count` | +| `foo_bar.baz.qux-yay` | `Foo_bar_Baz_Qux_Yay` | + +**pretty**: Same as normal, but afterwards it will apply the `snakeToCamelCase` filter, and other manipulations, for a prettier (but still valid) identifier. + +| Input | Output | +|------------------------|----------------------| +| `hello` | `Hello` | +| `42hello` | `_42hello` | +| `some$URL` | `SomeURL` | +| `25 Ultra Light` | `_25UltraLight` | +| `26_extra_ultra_light` | `_26ExtraUltraLight` | +| `apples.count` | `ApplesCount` | +| `foo_bar.baz.qux-yay` | `FooBarBazQuxYay` | + ## Filter: `titlecase` diff --git a/README.md b/README.md index 32d78fc6..2b187253 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ * `removeNewlines`: Removes newlines and other whitespace characters, depending on the mode ("all" or "leading"). * `replace`: Replaces instances of a substring with a new string. * `snakeToCamelCase`: Transforms text from snake_case to camelCase. By default it keeps leading underscores, unless a single optional argument is set to "true", "yes" or "1". - * `swiftIdentifier`: Transforms an arbitrary string into a valid Swift identifier (using only valid characters for a Swift identifier as defined in the Swift language reference) + * `swiftIdentifier`: Transforms an arbitrary string into a valid Swift identifier (using only valid characters for a Swift identifier as defined in the Swift language reference). In "pretty" mode, it will also apply the snakeToCamelCase filter afterwards, and other manipulations if needed for a "prettier" but still valid identifier. * `upperFirstLetter`: Uppercases only the first character * [Number filters](Documentation/filters-numbers.md): * `int255toFloat` diff --git a/Sources/Filters+Strings.swift b/Sources/Filters+Strings.swift index a6133dbc..b4671b46 100644 --- a/Sources/Filters+Strings.swift +++ b/Sources/Filters+Strings.swift @@ -11,6 +11,10 @@ enum RemoveNewlinesModes: String { case all, leading } +enum SwiftIdentifierModes: String { + case normal, pretty +} + extension Filters { enum Strings { fileprivate static let reservedKeywords = [ @@ -50,9 +54,29 @@ extension Filters { return source.replacingOccurrences(of: substring, with: replacement) } - static func swiftIdentifier(_ value: Any?) throws -> Any? { - guard let value = value as? String else { throw Filters.Error.invalidInputType } - return StencilSwiftKit.swiftIdentifier(from: value, replaceWithUnderscores: true) + /// Converts an arbitrary string to a valid swift identifier. Takes an optional Mode argument: + /// - normal (default): uppercase the first character, prefix with an underscore if starting + /// with a number, replace invalid characters by underscores + /// - leading: same as the above, but apply the snaceToCamelCase filter first for a nicer + /// identifier + /// + /// - Parameters: + /// - value: the value to be processed + /// - arguments: the arguments to the function; expecting zero or one mode argument + /// - Returns: the identifier string + /// - Throws: FilterError.invalidInputType if the value parameter isn't a string + static func swiftIdentifier(_ value: Any?, arguments: [Any?]) throws -> Any? { + guard var string = value as? String else { throw Filters.Error.invalidInputType } + let mode = try Filters.parseEnum(from: arguments, default: SwiftIdentifierModes.normal) + + switch mode { + case .normal: + return SwiftIdentifier.identifier(from: string, replaceWithUnderscores: true) + case .pretty: + string = SwiftIdentifier.identifier(from: string, replaceWithUnderscores: true) + string = try snakeToCamelCase(string, stripLeading: true) + return SwiftIdentifier.prefixWithUnderscoreIfNeeded(string: string) + } } /// Lowers the first letter of the string @@ -112,25 +136,7 @@ extension Filters { let stripLeading = try Filters.parseBool(from: arguments, required: false) ?? false guard let string = value as? String else { throw Filters.Error.invalidInputType } - let unprefixed: String - if try containsAnyLowercasedChar(string) { - let comps = string.components(separatedBy: "_") - unprefixed = comps.map { titlecase($0) }.joined(separator: "") - } else { - let comps = try snakecase(string).components(separatedBy: "_") - unprefixed = comps.map { $0.capitalized }.joined(separator: "") - } - - // only if passed true, strip the prefix underscores - var prefixUnderscores = "" - if !stripLeading { - for scalar in string.unicodeScalars { - guard scalar == "_" else { break } - prefixUnderscores += "_" - } - } - - return prefixUnderscores + unprefixed + return try snakeToCamelCase(string, stripLeading: stripLeading) } /// Converts camelCase to snake_case. Takes an optional Bool argument for making the string lower case, @@ -253,6 +259,34 @@ extension Filters { return String(chars) } + /// Converts snake_case to camelCase, stripping prefix underscores if needed + /// + /// - Parameters: + /// - string: the value to be processed + /// - stripLeading: if false, will preserve leading underscores + /// - Returns: the camel case string + static func snakeToCamelCase(_ string: String, stripLeading: Bool) throws -> String { + let unprefixed: String + if try containsAnyLowercasedChar(string) { + let comps = string.components(separatedBy: "_") + unprefixed = comps.map { titlecase($0) }.joined(separator: "") + } else { + let comps = try snakecase(string).components(separatedBy: "_") + unprefixed = comps.map { $0.capitalized }.joined(separator: "") + } + + // only if passed true, strip the prefix underscores + var prefixUnderscores = "" + if !stripLeading { + for scalar in string.unicodeScalars { + guard scalar == "_" else { break } + prefixUnderscores += "_" + } + } + + return prefixUnderscores + unprefixed + } + /// This returns the string with its first parameter uppercased. /// - note: This is quite similar to `capitalise` except that this filter doesn't /// lowercase the rest of the string but keeps it untouched. diff --git a/Sources/SwiftIdentifier.swift b/Sources/SwiftIdentifier.swift index 68d9e5e8..b470e9da 100644 --- a/Sources/SwiftIdentifier.swift +++ b/Sources/SwiftIdentifier.swift @@ -32,7 +32,7 @@ private let tailRanges: [CountableClosedRange] = [ 0x30...0x39, 0x300...0x36F, 0x1dc0...0x1dff, 0x20d0...0x20ff, 0xfe20...0xfe2f ] -private func identifierCharacterSets() -> (head: NSMutableCharacterSet, tail: NSMutableCharacterSet) { +private func identifierCharacterSets(exceptions: String) -> (head: NSMutableCharacterSet, tail: NSMutableCharacterSet) { let addRange: (NSMutableCharacterSet, CountableClosedRange) -> Void = { (mcs, range) in mcs.addCharacters(in: NSRange(location: range.lowerBound, length: range.count)) } @@ -41,6 +41,7 @@ private func identifierCharacterSets() -> (head: NSMutableCharacterSet, tail: NS for range in headRanges { addRange(head, range) } + head.removeCharacters(in: exceptions) guard let tail = head.mutableCopy() as? NSMutableCharacterSet else { fatalError("Internal error: mutableCopy() should have returned a valid NSMutableCharacterSet") @@ -48,35 +49,46 @@ private func identifierCharacterSets() -> (head: NSMutableCharacterSet, tail: NS for range in tailRanges { addRange(tail, range) } + tail.removeCharacters(in: exceptions) return (head, tail) } -func swiftIdentifier(from string: String, - forbiddenChars exceptions: String = "", - replaceWithUnderscores underscores: Bool = false) -> String { +enum SwiftIdentifier { + static func identifier(from string: String, + forbiddenChars exceptions: String = "", + replaceWithUnderscores underscores: Bool = false) -> String { - let (head, tail) = identifierCharacterSets() - head.removeCharacters(in: exceptions) - tail.removeCharacters(in: exceptions) + let (_, tail) = identifierCharacterSets(exceptions: exceptions) + + let parts = string.components(separatedBy: tail.inverted) + let replacement = underscores ? "_" : "" + let mappedParts = parts.map({ (string: String) -> String in + // Can't use capitalizedString here because it will lowercase all letters after the first + // e.g. "SomeNiceIdentifier".capitalizedString will because "Someniceidentifier" which is not what we want + let ns = NSString(string: string) + if ns.length > 0 { + let firstLetter = ns.substring(to: 1) + let rest = ns.substring(from: 1) + return firstLetter.uppercased() + rest + } else { + return "" + } + }) - let chars = string.unicodeScalars - let firstChar = chars[chars.startIndex] - - let prefix = !head.longCharacterIsMember(firstChar.value) && tail.longCharacterIsMember(firstChar.value) ? "_" : "" - let parts = string.components(separatedBy: tail.inverted) - let replacement = underscores ? "_" : "" - let mappedParts = parts.map({ (string: String) -> String in - // Can't use capitalizedString here because it will lowercase all letters after the first - // e.g. "SomeNiceIdentifier".capitalizedString will because "Someniceidentifier" which is not what we want - let ns = NSString(string: string) - if ns.length > 0 { - let firstLetter = ns.substring(to: 1) - let rest = ns.substring(from: 1) - return firstLetter.uppercased() + rest - } else { - return "" - } - }) - return prefix + mappedParts.joined(separator: replacement) + let result = mappedParts.joined(separator: replacement) + return prefixWithUnderscoreIfNeeded(string: result, forbiddenChars: exceptions) + } + + static func prefixWithUnderscoreIfNeeded(string: String, + forbiddenChars exceptions: String = "") -> String { + + let (head, _) = identifierCharacterSets(exceptions: exceptions) + + let chars = string.unicodeScalars + let firstChar = chars[chars.startIndex] + let prefix = !head.longCharacterIsMember(firstChar.value) ? "_" : "" + + return prefix + string + } } diff --git a/Tests/StencilSwiftKitTests/SwiftIdentifierTests.swift b/Tests/StencilSwiftKitTests/SwiftIdentifierTests.swift index 922fe4af..b979ce39 100644 --- a/Tests/StencilSwiftKitTests/SwiftIdentifierTests.swift +++ b/Tests/StencilSwiftKitTests/SwiftIdentifierTests.swift @@ -8,42 +8,107 @@ import XCTest @testable import StencilSwiftKit class SwiftIdentifierTests: XCTestCase { - func testBasicString() { - XCTAssertEqual(swiftIdentifier(from: "Hello"), "Hello") + XCTAssertEqual(SwiftIdentifier.identifier(from: "Hello"), "Hello") } func testBasicStringWithForbiddenChars() { - XCTAssertEqual(swiftIdentifier(from: "Hello", forbiddenChars: "l"), "HeO") + XCTAssertEqual(SwiftIdentifier.identifier(from: "Hello", forbiddenChars: "l"), "HeO") } func testBasicStringWithForbiddenCharsAndUnderscores() { - XCTAssertEqual(swiftIdentifier(from: "Hello", forbiddenChars: "l", replaceWithUnderscores: true), "He__O") + XCTAssertEqual(SwiftIdentifier.identifier(from: "Hello", + forbiddenChars: "l", + replaceWithUnderscores: true), "He__O") } func testSpecialChars() { - XCTAssertEqual(swiftIdentifier(from: "This-is-42$hello@world"), "ThisIs42HelloWorld") + XCTAssertEqual(SwiftIdentifier.identifier(from: "This-is-42$hello@world"), "ThisIs42HelloWorld") } func testKeepUppercaseAcronyms() { - XCTAssertEqual(swiftIdentifier(from: "some$URLDecoder"), "SomeURLDecoder") + XCTAssertEqual(SwiftIdentifier.identifier(from: "some$URLDecoder"), "SomeURLDecoder") } func testEmojis() { - XCTAssertEqual(swiftIdentifier(from: "some😎🎉emoji"), "Some😎🎉emoji") + XCTAssertEqual(SwiftIdentifier.identifier(from: "some😎🎉emoji"), "Some😎🎉emoji") } func testEmojis2() { - XCTAssertEqual(swiftIdentifier(from: "😎🎉"), "😎🎉") + XCTAssertEqual(SwiftIdentifier.identifier(from: "😎🎉"), "😎🎉") } func testNumbersFirst() { - XCTAssertEqual(swiftIdentifier(from: "42hello"), "_42hello") + XCTAssertEqual(SwiftIdentifier.identifier(from: "42hello"), "_42hello") } func testForbiddenChars() { XCTAssertEqual( - swiftIdentifier(from: "hello$world^this*contains%a=lot@ofchars!does#it/still:work.anyway?"), + SwiftIdentifier.identifier(from: "hello$world^this*contains%a=lot@ofchars!does#it/still:work.anyway?"), "HelloWorldThisContainsALotOfForbiddenCharsDoesItStillWorkAnyway") } } + +extension SwiftIdentifierTests { + func testSwiftIdentifier_WithNoArgsDefaultsToNormal() throws { + let result = try Filters.Strings.swiftIdentifier("some_test", arguments: []) as? String + XCTAssertEqual(result, "Some_test") + } + + func testSwiftIdentifier_WithWrongArgWillThrow() throws { + do { + _ = try Filters.Strings.swiftIdentifier("", arguments: ["wrong"]) + XCTFail("Code did succeed while it was expected to fail for wrong option") + } catch Filters.Error.invalidOption { + // That's the expected exception we want to happen + } catch let error { + XCTFail("Unexpected error occured: \(error)") + } + } + + func testSwiftIdentifier_WithNormal() throws { + let expectations = [ + "hello": "Hello", + "42hello": "_42hello", + "some$URL": "Some_URL", + "with space": "With_Space", + "apples.count": "Apples_Count", + ".SFNSDisplay": "_SFNSDisplay", + "Show-NavCtrl": "Show_NavCtrl", + "HEADER_TITLE": "HEADER_TITLE", + "multiLine\nKey": "MultiLine_Key", + "foo_bar.baz.qux-yay": "Foo_bar_Baz_Qux_Yay", + "25 Ultra Light": "_25_Ultra_Light", + "26_extra_ultra_light": "_26_extra_ultra_light", + "12 @ 34 % 56 + 78 Hello world": "_12___34___56___78_Hello_World" + ] + + for (input, expected) in expectations { + let result = try Filters.Strings.swiftIdentifier(input, arguments: ["normal"]) as? String + XCTAssertEqual(result, expected) + } + } + + func testSwiftIdentifier_WithPretty() throws { + let expectations = [ + "hello": "Hello", + "42hello": "_42hello", + "some$URL": "SomeURL", + "with space": "WithSpace", + "apples.count": "ApplesCount", + ".SFNSDisplay": "SFNSDisplay", + "Show-NavCtrl": "ShowNavCtrl", + "HEADER_TITLE": "HeaderTitle", + "multiLine\nKey": "MultiLineKey", + "foo_bar.baz.qux-yay": "FooBarBazQuxYay", + "25 Ultra Light": "_25UltraLight", + "26_extra_ultra_light": "_26ExtraUltraLight", + "12 @ 34 % 56 + 78 Hello world": "_12345678HelloWorld" + ] + + for (input, expected) in expectations { + let result = try Filters.Strings.swiftIdentifier(input, arguments: ["pretty"]) as? String + XCTAssertEqual(result, expected) + } + } +}