Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pretty swiftidentifier #61

Merged
merged 6 commits into from
Aug 20, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
31 changes: 25 additions & 6 deletions Documentation/filters-strings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, really, does that filter actually uppercase the first character all the time in our code (even since Swift 3)? Shouldn't by anymore I think… (if it's still the case, might be worth using that PR to make the normal mode correct again and stop uppercasing while there's not reason to?)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That... would be a breaking change TBH. And for what? Types will still need an uppercase first letter, so we'll still need to apply a filter.

That's what I meant to say in the discussion in #30, add enum cases for swift versions, and maybe even for the type of identifier (variable, case, enum, struct, etc...). Maybe not so extensive (not so many options), but could be possible.

Although upper first letter is a decent default.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well it's debatable. The aim of swiftIdentifier is only to make the string valid Swift. Prettifying the string is nice too, but transforming too much means that if we don't want that transformation we have to apply the reverse of that transform afterwards to cancel it… And that reverse transform may not always exist.

Just feels odd to over-transform when half of the cases would need it, sure, but the other half doesn't and would need to revert it, so why force to do it in the first place instead of letting it be opt-in is what I mean. The history of this comes back from Swift 2 of course, but we're passed that now.

But indeed that would be a breaking change so maybe let's keep that for a later PR :-/

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thing is, snakeToCamelCase would uppercase the first letter anyway, no way around that.

And I'm not talking about swift 2, but even swift 3/4 types need an uppercase first letter. Only variables and cases don't.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, Right

- Prefix with an underscore if the first character is a number.
Expand All @@ -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`

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
78 changes: 56 additions & 22 deletions Sources/Filters+Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ enum RemoveNewlinesModes: String {
case all, leading
}

enum SwiftIdentifierModes: String {
case normal, pretty
}

extension Filters {
enum Strings {
fileprivate static let reservedKeywords = [
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
64 changes: 38 additions & 26 deletions Sources/SwiftIdentifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ private let tailRanges: [CountableClosedRange<Int>] = [
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<Int>) -> Void = { (mcs, range) in
mcs.addCharacters(in: NSRange(location: range.lowerBound, length: range.count))
}
Expand All @@ -41,42 +41,54 @@ 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")
}
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
}
}
85 changes: 75 additions & 10 deletions Tests/StencilSwiftKitTests/SwiftIdentifierTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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@of<forbidden>chars!does#it/still:work.anyway?"),
SwiftIdentifier.identifier(from: "hello$world^this*contains%a=lot@of<forbidden>chars!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)
}
}
}