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

Interface Builder: accessibility identifiers #369

Open
wants to merge 5 commits into
base: stable
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ _None_

### New Features

_None_
* Identifiers: generate accessibility identifiers using `swiftgen identifiers`.
[Ian Keen](https://github.com/iankeen)
[#369](https://github.com/SwiftGen/SwiftGen/pull/369)


### Internal Changes

Expand Down
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ SwiftGen is a tool to auto-generate Swift code for resources of your projects, t
<li><a href="#fonts">Fonts</a>
<li><a href="#storyboards">Storyboards and their Scenes</a>
<li><a href="#strings"><tt>Localizable.strings</tt></a>
<li><a href="#identifiers">Identifiers</a>
</ul>
</td>
</tr></table>
Expand Down Expand Up @@ -525,6 +526,46 @@ let apples = L10n.applesCount(3)
let bananas = L10n.bananasOwner(5, "Olivier")
```

## Identifiers

```sh
swiftgen identifiers -t enums /dir/to/search/for/interface/builder/files
```

This will generate an `enum AccessibilityIdentifier`. For each container (controllers, cells etc) containing accessibility identifiers. It will generate a nested enum for each container containing a case for each identifier, so that you can use them as constants.

<details>
<summary>Example of code generated by the `enums` template</summary>

```swift
enum AccessibilityIdentifier {
enum MyViewController: String {
case emailtextField = "emailtextField"
case loginButton = "loginButton"
}
}

```
</details>

### Static Property template

Alternatively you can use the `properties` template to get static properties for each identifier instead

<details>
<summary>Example of code generated by the `properties` template</summary>

```swift
enum AccessibilityIdentifier {
enum MyViewController {
static let emailtextField = "emailtextField"
static let loginButton = "loginButton"
}
}

```
</details>

---


Expand Down
6 changes: 6 additions & 0 deletions Sources/SwiftGen/ParserCLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,11 @@ let allParserCommands: [ParserCLI] = [
name: "fonts",
description: "generate code for your fonts",
pathDescription: "Directory(ies) to parse."
),
.init(
parserType: IdentifiersParser.self,
name: "identifiers",
description: "generate code for your accessibility identifiers",
pathDescription: "Directory to scan for .storyboard/.xib files. Can also be a path to a single .storyboard/.xib"
)
]
165 changes: 165 additions & 0 deletions Sources/SwiftGenKit/Parsers/IdentifiersParser.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
//
// SwiftGenKit
// Copyright (c) 2017 SwiftGen
// MIT Licence
//

import Foundation
import Kanna
import PathKit

public enum IdentifiersParserError: Error, CustomStringConvertible {
case xmlParseError(path: Path)

public var description: String {
switch self {
case .xmlParseError(let path):
return "error: Unable to parse \(path). "
}
}
}

private enum Constants {
static let identifierXPath = "//accessibility/@identifier"
static let placeholderXPath = "placeholder[@placeholderIdentifier=\"IBFilesOwner\"]"
static let customObjectXPath = "customObject[@userLabel=\"File's Owner\"]"

static let customClassAttribute = "customClass"
static let containerTags = [
"viewController", "tableViewController", "collectionViewCell", "tableViewCell", "pagecontroller", "objects"
]

static let storyboard = "storyboard"
static let xib = "xib"
}

struct AccessibilityItem {
let elementType: String
let identifier: String
let rawValue: String
}

public final class IdentifiersParser: Parser {
typealias IdentifiersData = [String: [AccessibilityItem]]

var accessibilityIdentifiers: IdentifiersData = [:]
public var warningHandler: Parser.MessageHandler?

public init(options: [String: Any] = [:], warningHandler: Parser.MessageHandler? = nil) {
self.warningHandler = warningHandler
}

public func parse(path: Path) throws {
switch path.extension {
case Constants.storyboard?, Constants.xib?:
try extractData(from: path)

case nil:
let dirChildren = path.iterateChildren(options: [.skipsHiddenFiles, .skipsPackageDescendants])

for file in dirChildren {
try parse(path: file)
}

default:
break
}
}

private func extractData(from path: Path) throws {
guard let document = Kanna.XML(xml: try path.read(), encoding: .utf8) else {
throw IdentifiersParserError.xmlParseError(path: path)
}

typealias Pair = (String, AccessibilityItem)
let pairs: [Pair] = document.xpath(Constants.identifierXPath).flatMap { element in
guard
let customClass = customClassFor(element: element, in: path),
let identifier = element.text,
let elementType = element.parent?.parent?.tagName
else { return nil }

let item = AccessibilityItem(elementType: elementType, identifier: identifier.sanitized(), rawValue: identifier)

return (customClass, item)
}

let identifiers: IdentifiersData = pairs.grouped(
key: { $0.0 },
value: { $0.1 }
)

accessibilityIdentifiers += identifiers
}
private func customClassFor(element: Kanna.XMLElement, in path: Path) -> String? {
switch path.extension {
case Constants.storyboard?, Constants.xib?:
guard let parent = element.parent(tagNames: Constants.containerTags) else { return nil }

if let className = parent[Constants.customClassAttribute] {
return className
} else if let placeholder = parent.xpath(Constants.placeholderXPath).first {
return placeholder[Constants.customClassAttribute]
} else if let placeholder = parent.xpath(Constants.customObjectXPath).first {
return placeholder[Constants.customClassAttribute]
} else {
return nil
}

default:
return nil
}
}
}

extension Sequence {
func grouped<Key, Value>(key: (Element) -> Key, value: (Element) -> Value) -> [Key: [Value]] {
return Dictionary(grouping: self, by: key).mapValues { groupedValues in
return groupedValues.map(value)
}
}
}

func +=<Key, Value>(lhs: inout [Key: Value], rhs: [Key: Value]) {
for (key, value) in rhs {
lhs[key] = value
}
}

extension Kanna.XMLElement {
func parent(tagNames: [String]) -> Kanna.XMLElement? {
var parent: Kanna.XMLElement = self
var match: Kanna.XMLElement? = nil

while let element = parent.parent {
defer { parent = element }

guard
let tagName = element.tagName,
tagNames.contains(tagName)
else { continue }

match = element
break
}

return match
}
}

let reservedWords: Set<String> = [
"Protocol", "Type", "associatedtype", "class", "deinit", "enum", "extension",
"fileprivate", "func", "import", "init", "inout", "internal", "let",
"operator", "private", "protocol", "public", "static", "struct", "subscript",
"typealias", "var", "_", "break", "case", "continue", "default", "defer", "do",
"else", "fallthrough", "for", "guard", "if", "in", "repeat", "return", "switch",
"where", "while", "as", "Any", "catch", "false", "is", "nil", "rethrows",
"super", "self", "Self", "throw", "throws", "true", "try"
]

extension String {
func sanitized() -> String {
let value = self.components(separatedBy: CharacterSet.alphanumerics.inverted).joined(separator: "_")
return reservedWords.contains(value) ? "`\(value)`" : value
}
}
38 changes: 38 additions & 0 deletions Sources/SwiftGenKit/Stencil/IdentifiersContext.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// SwiftGenKit
// Copyright (c) 2017 SwiftGen
// MIT Licence
//

import Foundation

/*
- `containers`: `Array` — list of Dictionaries, each containing a container and its identifiers
- `name` : `String` — name of the container
- `accessibility`: `Array` — list of accessibility objects in the container
- `elementType`: `String` - Type of element
- `identifier`: `String` - Accessibility identifier of the object (sanitised for use in code)
- `rawValue`: `String` - Raw accessibility identifier as it exists in interface builder
*/
extension IdentifiersParser {
public func stencilContext() -> [String: Any] {
let containers = accessibilityIdentifiers.keys.sorted()

return [
"containers": containers.map { name in
return [
"name": name,
"accessibility": accessibilityIdentifiers[name]! // swiftlint:disable:this force_unwrapping
.sorted { $0.identifier < $1.identifier }
.map { item in
return [
"elementType": item.elementType,
"identifier": item.identifier,
"rawValue": item.rawValue
]
}
]
}
]
}
}
24 changes: 24 additions & 0 deletions SwiftGen.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@
234F7E7D1DEBD765001B3C10 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09C7B2D31BCC382800D7F488 /* main.swift */; };
234F7E7E1DEBD765001B3C10 /* Path+Commander.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091150301BD31A3300EBC803 /* Path+Commander.swift */; };
234F7E7F1DEBD765001B3C10 /* TemplatesCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0921FBA01BE7D6B900B488B5 /* TemplatesCommands.swift */; };
32091B001FDE42DB00B1E2B4 /* IdentifiersParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32091AFF1FDE42DB00B1E2B4 /* IdentifiersParser.swift */; };
32091B021FDE5B8F00B1E2B4 /* IdentifiersContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32091B011FDE5B8F00B1E2B4 /* IdentifiersContext.swift */; };
32406FA11FE0B00A00DE2B75 /* IdentifiersiOSTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32406FA01FE0B00A00DE2B75 /* IdentifiersiOSTests.swift */; };
32406FD11FE0D8AD00DE2B75 /* IdentifiersMacOSTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32406FD01FE0D8AD00DE2B75 /* IdentifiersMacOSTests.swift */; };
32406FD31FE0ECFB00DE2B75 /* IdentifiersiOSTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32406FD21FE0ECFB00DE2B75 /* IdentifiersiOSTests.swift */; };
32406FD51FE0F81C00DE2B75 /* IdentifiersMacOSTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32406FD41FE0F81C00DE2B75 /* IdentifiersMacOSTests.swift */; };
379C7E871F8ECA0C00267096 /* TemplateRef.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379C7E861F8ECA0C00267096 /* TemplateRef.swift */; };
379C7E891F8ECA7000267096 /* OutputDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379C7E881F8ECA7000267096 /* OutputDestination.swift */; };
379C7E8A1F8ECA8900267096 /* TemplateRef.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379C7E861F8ECA0C00267096 /* TemplateRef.swift */; };
Expand Down Expand Up @@ -239,6 +245,12 @@
234F7E6E1DEBD5C5001B3C10 /* swiftgen.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = swiftgen.app; sourceTree = BUILT_PRODUCTS_DIR; };
234F7E771DEBD5C5001B3C10 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
24D0E9A6205497F0D1C00AAD /* Pods-SwiftGenKit.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SwiftGenKit.release.xcconfig"; path = "Pods/Target Support Files/Pods-SwiftGenKit/Pods-SwiftGenKit.release.xcconfig"; sourceTree = "<group>"; };
32091AFF1FDE42DB00B1E2B4 /* IdentifiersParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifiersParser.swift; sourceTree = "<group>"; };
32091B011FDE5B8F00B1E2B4 /* IdentifiersContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifiersContext.swift; sourceTree = "<group>"; };
32406FA01FE0B00A00DE2B75 /* IdentifiersiOSTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifiersiOSTests.swift; sourceTree = "<group>"; };
32406FD01FE0D8AD00DE2B75 /* IdentifiersMacOSTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifiersMacOSTests.swift; sourceTree = "<group>"; };
32406FD21FE0ECFB00DE2B75 /* IdentifiersiOSTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifiersiOSTests.swift; sourceTree = "<group>"; };
32406FD41FE0F81C00DE2B75 /* IdentifiersMacOSTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifiersMacOSTests.swift; sourceTree = "<group>"; };
379C7E861F8ECA0C00267096 /* TemplateRef.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateRef.swift; sourceTree = "<group>"; };
379C7E881F8ECA7000267096 /* OutputDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutputDestination.swift; sourceTree = "<group>"; };
379C7E8C1F8ECB2400267096 /* Path+AppSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Path+AppSupport.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -504,6 +516,7 @@
DD428E0F1FC50FBF001649D6 /* Parser.swift */,
DD428E101FC50FBF001649D6 /* StringsParser.swift */,
DD428E111FC50FBF001649D6 /* ColorsParser.swift */,
32091AFF1FDE42DB00B1E2B4 /* IdentifiersParser.swift */,
);
path = Parsers;
sourceTree = "<group>";
Expand All @@ -527,6 +540,7 @@
DD428E151FC50FBF001649D6 /* AssetsCatalogContext.swift */,
DD428E161FC50FBF001649D6 /* StoryboardsContext.swift */,
DD428E171FC50FBF001649D6 /* StringsContext.swift */,
32091B011FDE5B8F00B1E2B4 /* IdentifiersContext.swift */,
);
path = Stencil;
sourceTree = "<group>";
Expand Down Expand Up @@ -580,6 +594,8 @@
DDDE50F81FAF6AD4004203BE /* TestsHelper.swift */,
DDDE50F91FAF6AD4004203BE /* StoryboardsiOSTests.swift */,
DDDE50FA1FAF6AD4004203BE /* Info.plist */,
32406FD21FE0ECFB00DE2B75 /* IdentifiersiOSTests.swift */,
32406FD41FE0F81C00DE2B75 /* IdentifiersMacOSTests.swift */,
);
path = TemplatesTests;
sourceTree = "<group>";
Expand All @@ -600,6 +616,8 @@
DDDE521E1FAF81BA004203BE /* StoryboardsiOSTests.swift */,
DDDE521F1FAF81BA004203BE /* Info.plist */,
DDDE52201FAF81BA004203BE /* AssetCatalogTests.swift */,
32406FA01FE0B00A00DE2B75 /* IdentifiersiOSTests.swift */,
32406FD01FE0D8AD00DE2B75 /* IdentifiersMacOSTests.swift */,
);
path = SwiftGenKitTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -1187,7 +1205,9 @@
DD428E201FC50FBF001649D6 /* Parser.swift in Sources */,
DD428E1E1FC50FBF001649D6 /* AssetsCatalogParser.swift in Sources */,
DD428E271FC50FBF001649D6 /* StringsContext.swift in Sources */,
32091B021FDE5B8F00B1E2B4 /* IdentifiersContext.swift in Sources */,
DD428E211FC50FBF001649D6 /* StringsParser.swift in Sources */,
32091B001FDE42DB00B1E2B4 /* IdentifiersParser.swift in Sources */,
DD428E231FC50FBF001649D6 /* FontsContext.swift in Sources */,
DD428E191FC50FBF001649D6 /* FontsParser.swift in Sources */,
DD428E1C1FC50FBF001649D6 /* ColorsTXTFileParser.swift in Sources */,
Expand All @@ -1200,8 +1220,10 @@
files = (
DDDE51961FAF6AD4004203BE /* StoryboardsMacOSTests.swift in Sources */,
DDDE51981FAF6AD4004203BE /* StoryboardsiOSTests.swift in Sources */,
32406FD51FE0F81C00DE2B75 /* IdentifiersMacOSTests.swift in Sources */,
DDDE51971FAF6AD4004203BE /* TestsHelper.swift in Sources */,
DDDE51941FAF6AD4004203BE /* XCAssetsTests.swift in Sources */,
32406FD31FE0ECFB00DE2B75 /* IdentifiersiOSTests.swift in Sources */,
DDDE51951FAF6AD4004203BE /* ColorsTests.swift in Sources */,
DDDE51141FAF6AD4004203BE /* FontsTests.swift in Sources */,
DDDE51131FAF6AD4004203BE /* StringsTests.swift in Sources */,
Expand All @@ -1214,6 +1236,7 @@
files = (
DDDE52711FAF81BA004203BE /* StoryboardsMacOSTests.swift in Sources */,
DDDE52731FAF81BA004203BE /* StoryboardsiOSTests.swift in Sources */,
32406FD11FE0D8AD00DE2B75 /* IdentifiersMacOSTests.swift in Sources */,
DDDE52721FAF81BA004203BE /* TestsHelper.swift in Sources */,
DDDE523B1FAF81BA004203BE /* FontsTests.swift in Sources */,
DDDE523A1FAF81BA004203BE /* StringsTests.swift in Sources */,
Expand All @@ -1223,6 +1246,7 @@
DDDE523F1FAF81BA004203BE /* ColorsXMLFileTests.swift in Sources */,
DDDE523C1FAF81BA004203BE /* ColorsJSONFileTests.swift in Sources */,
DDDE52701FAF81BA004203BE /* ColorsTextFileTests.swift in Sources */,
32406FA11FE0B00A00DE2B75 /* IdentifiersiOSTests.swift in Sources */,
DDDE523E1FAF81BA004203BE /* ColorsTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down