From eef8078435070cd135c5717514d5fb08b208e7ff Mon Sep 17 00:00:00 2001 From: Rob Amos Date: Tue, 17 Dec 2024 22:14:00 +1100 Subject: [PATCH] Calculate flag display names at compile time instead of runtime --- .../Vexil/Observability/FlagGroupWigwag.swift | 7 +- Sources/Vexil/Observability/FlagWigwag.swift | 7 +- Sources/VexilMacros/FlagGroupMacro.swift | 4 +- Sources/VexilMacros/FlagMacro.swift | 2 +- .../VexilMacros/Utilities/DisplayName.swift | 88 +++++++++++++++++++ .../EquatableFlagContainerMacroTests.swift | 12 +-- .../FlagContainerMacroTests.swift | 2 +- .../VexilMacroTests/FlagGroupMacroTests.swift | 24 ++--- Tests/VexilMacroTests/FlagMacroTests.swift | 34 +++---- Tests/VexilTests/FlagDetailTests.swift | 4 +- 10 files changed, 135 insertions(+), 49 deletions(-) create mode 100644 Sources/VexilMacros/Utilities/DisplayName.swift diff --git a/Sources/Vexil/Observability/FlagGroupWigwag.swift b/Sources/Vexil/Observability/FlagGroupWigwag.swift index 70eaa783..4950c20c 100644 --- a/Sources/Vexil/Observability/FlagGroupWigwag.swift +++ b/Sources/Vexil/Observability/FlagGroupWigwag.swift @@ -36,9 +36,8 @@ public struct FlagGroupWigwag: Sendable where Output: FlagContainer { keyPath.key } - /// An optional display name to give the flag. Only visible in flag editors like Vexillographer. - /// Default is to calculate one based on the property name. - public let name: String? + /// A human readable name for the flag group. Only visible in flag editors like Vexillographer. + public let name: String /// A description of this flag. Only visible in flag editors like Vexillographer. /// If this is nil the flag or flag group will be hidden. @@ -56,7 +55,7 @@ public struct FlagGroupWigwag: Sendable where Output: FlagContainer { /// Creates a Wigwag with the provided configuration. public init( keyPath: FlagKeyPath, - name: String?, + name: String, description: String?, displayOption: FlagGroupDisplayOption?, lookup: any FlagLookup diff --git a/Sources/Vexil/Observability/FlagWigwag.swift b/Sources/Vexil/Observability/FlagWigwag.swift index e08dcaaf..a747e3fd 100644 --- a/Sources/Vexil/Observability/FlagWigwag.swift +++ b/Sources/Vexil/Observability/FlagWigwag.swift @@ -39,9 +39,8 @@ public struct FlagWigwag: Sendable where Output: FlagValue { /// The default value for this flag public let defaultValue: Output - /// An optional display name to give the flag. Only visible in flag editors like Vexillographer. - /// Default is to calculate one based on the property name. - public let name: String? + /// A human readable name for the flag. Only visible in flag editors like Vexillographer. + public let name: String /// A description of this flag. Only visible in flag editors like Vexillographer. /// If this is nil the flag or flag group will be hidden. @@ -59,7 +58,7 @@ public struct FlagWigwag: Sendable where Output: FlagValue { /// Creates a Wigwag with the provided configuration. public init( keyPath: FlagKeyPath, - name: String?, + name: String, defaultValue: Output, description: String?, displayOption: FlagDisplayOption, diff --git a/Sources/VexilMacros/FlagGroupMacro.swift b/Sources/VexilMacros/FlagGroupMacro.swift index 97e440fb..a943ab02 100644 --- a/Sources/VexilMacros/FlagGroupMacro.swift +++ b/Sources/VexilMacros/FlagGroupMacro.swift @@ -79,7 +79,7 @@ public struct FlagGroupMacro { wigwag: { FlagGroupWigwag<\(type)>( keyPath: \(key), - name: \(name ?? "nil"), + name: \(name ?? ExprSyntax(StringLiteralExprSyntax(content: propertyName.displayName))), description: \(description ?? "nil"), displayOption: \(displayOption ?? ".navigation"), lookup: _flagLookup @@ -97,7 +97,7 @@ public struct FlagGroupMacro { """ FlagGroupWigwag( keyPath: \(key), - name: \(name ?? "nil"), + name: \(name ?? ExprSyntax(StringLiteralExprSyntax(content: propertyName.displayName))), description: \(description ?? "nil"), displayOption: \(displayOption ?? ".navigation"), lookup: _flagLookup diff --git a/Sources/VexilMacros/FlagMacro.swift b/Sources/VexilMacros/FlagMacro.swift index 211e62fe..4e32d73f 100644 --- a/Sources/VexilMacros/FlagMacro.swift +++ b/Sources/VexilMacros/FlagMacro.swift @@ -107,7 +107,7 @@ public struct FlagMacro { """ FlagWigwag( keyPath: \(key), - name: \(name ?? "nil"), + name: \(name ?? ExprSyntax(StringLiteralExprSyntax(content: propertyName.displayName))), defaultValue: \(defaultValue), description: \(description), displayOption: \(display ?? ".default"), diff --git a/Sources/VexilMacros/Utilities/DisplayName.swift b/Sources/VexilMacros/Utilities/DisplayName.swift new file mode 100644 index 00000000..a55f3a6d --- /dev/null +++ b/Sources/VexilMacros/Utilities/DisplayName.swift @@ -0,0 +1,88 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2024 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import Foundation + +extension String { + var displayName: String { + let uppercased = CharacterSet.uppercaseLetters + return (hasPrefix("_") ? String(dropFirst()) : self) + .separatedAtWordBoundaries + .map { CharacterSet(charactersIn: $0).isStrictSubset(of: uppercased) ? $0 : $0.capitalized } + .joined(separator: " ") + } + + /// Separates a string at word boundaries, eg. `oneTwoThree` becomes `one Two Three` + /// + /// Capital characters are determined by testing membership in `CharacterSet.uppercaseLetters` + /// and `CharacterSet.lowercaseLetters` (Unicode General Categories Lu and Lt). + /// The conversion to lower case uses `Locale.system`, also known as the ICU "root" locale. This means + /// the result is consistent regardless of the current user's locale and language preferences. + /// + /// Adapted from JSONEncoder's `toSnakeCase()` + /// + var separatedAtWordBoundaries: [String] { + guard !isEmpty else { + return [] + } + + let string = self + + var words: [Range] = [] + // The general idea of this algorithm is to split words on transition from lower to upper case, then on + // transition of >1 upper case characters to lowercase + // + // myProperty -> my_property + // myURLProperty -> my_url_property + // + // We assume, per Swift naming conventions, that the first character of the key is lowercase. + var wordStart = string.startIndex + var searchRange = string.index(after: wordStart) ..< string.endIndex + + let uppercase = CharacterSet.uppercaseLetters.union(CharacterSet.decimalDigits) + + // Find next uppercase character + while let upperCaseRange = string.rangeOfCharacter(from: uppercase, options: [], range: searchRange) { + let untilUpperCase = wordStart ..< upperCaseRange.lowerBound + words.append(untilUpperCase) + + // Find next lowercase character + searchRange = upperCaseRange.lowerBound ..< searchRange.upperBound + guard let lowerCaseRange = string.rangeOfCharacter(from: CharacterSet.lowercaseLetters, options: [], range: searchRange) else { + // There are no more lower case letters. Just end here. + wordStart = searchRange.lowerBound + break + } + + // Is the next lowercase letter more than 1 after the uppercase? If so, we encountered a group of uppercase + // letters that we should treat as its own word + let nextCharacterAfterCapital = string.index(after: upperCaseRange.lowerBound) + if lowerCaseRange.lowerBound == nextCharacterAfterCapital { + // The next character after capital is a lower case character and therefore not a word boundary. + // Continue searching for the next upper case for the boundary. + wordStart = upperCaseRange.lowerBound + } else { + // There was a range of >1 capital letters. Turn those into a word, stopping at the capital before the lower case character. + let beforeLowerIndex = string.index(before: lowerCaseRange.lowerBound) + words.append(upperCaseRange.lowerBound ..< beforeLowerIndex) + + // Next word starts at the capital before the lowercase we just found + wordStart = beforeLowerIndex + } + searchRange = lowerCaseRange.upperBound ..< searchRange.upperBound + } + words.append(wordStart ..< searchRange.upperBound) + + return words.map { string[$0].lowercased() } + } +} diff --git a/Tests/VexilMacroTests/EquatableFlagContainerMacroTests.swift b/Tests/VexilMacroTests/EquatableFlagContainerMacroTests.swift index b566cf32..223aaf8e 100644 --- a/Tests/VexilMacroTests/EquatableFlagContainerMacroTests.swift +++ b/Tests/VexilMacroTests/EquatableFlagContainerMacroTests.swift @@ -91,7 +91,7 @@ final class EquatableFlagContainerMacroTests: XCTestCase { var $someFlag: FlagWigwag { FlagWigwag( keyPath: _flagKeyPath.append(.automatic("some-flag")), - name: nil, + name: "Some Flag", defaultValue: false, description: "Some Flag", displayOption: .default, @@ -180,7 +180,7 @@ final class EquatableFlagContainerMacroTests: XCTestCase { var $someFlag: FlagWigwag { FlagWigwag( keyPath: _flagKeyPath.append(.automatic("some-flag")), - name: nil, + name: "Some Flag", defaultValue: false, description: "Some Flag", displayOption: .default, @@ -257,7 +257,7 @@ final class EquatableFlagContainerMacroTests: XCTestCase { var $someFlag: FlagWigwag { FlagWigwag( keyPath: _flagKeyPath.append(.automatic("some-flag")), - name: nil, + name: "Some Flag", defaultValue: false, description: "Some Flag", displayOption: .default, @@ -332,7 +332,7 @@ final class EquatableFlagContainerMacroTests: XCTestCase { var $someFlag: FlagWigwag { FlagWigwag( keyPath: _flagKeyPath.append(.automatic("some-flag")), - name: nil, + name: "Some Flag", defaultValue: false, description: "Some Flag", displayOption: .default, @@ -436,7 +436,7 @@ final class EquatableFlagContainerMacroTests: XCTestCase { wigwag: { FlagGroupWigwag( keyPath: _flagKeyPath.append(.automatic("flag-group")), - name: nil, + name: "Flag Group", description: "Test Group", displayOption: .navigation, lookup: _flagLookup @@ -530,7 +530,7 @@ final class EquatableFlagContainerMacroTests: XCTestCase { wigwag: { FlagGroupWigwag( keyPath: _flagKeyPath.append(.automatic("flag-group")), - name: nil, + name: "Flag Group", description: "Test Group", displayOption: .navigation, lookup: _flagLookup diff --git a/Tests/VexilMacroTests/FlagContainerMacroTests.swift b/Tests/VexilMacroTests/FlagContainerMacroTests.swift index c0accb8f..b5537ac9 100644 --- a/Tests/VexilMacroTests/FlagContainerMacroTests.swift +++ b/Tests/VexilMacroTests/FlagContainerMacroTests.swift @@ -184,7 +184,7 @@ final class FlagContainerMacroTests: XCTestCase { wigwag: { FlagGroupWigwag( keyPath: _flagKeyPath.append(.automatic("flag-group")), - name: nil, + name: "Flag Group", description: "Test Group", displayOption: .navigation, lookup: _flagLookup diff --git a/Tests/VexilMacroTests/FlagGroupMacroTests.swift b/Tests/VexilMacroTests/FlagGroupMacroTests.swift index ae68b8c6..c6cd3bd2 100644 --- a/Tests/VexilMacroTests/FlagGroupMacroTests.swift +++ b/Tests/VexilMacroTests/FlagGroupMacroTests.swift @@ -40,7 +40,7 @@ final class FlagGroupMacroTests: XCTestCase { var $testSubgroup: FlagGroupWigwag { FlagGroupWigwag( keyPath: _flagKeyPath.append(.automatic("test-subgroup")), - name: nil, + name: "Test Subgroup", description: "Test Flag Group", displayOption: .navigation, lookup: _flagLookup @@ -74,7 +74,7 @@ final class FlagGroupMacroTests: XCTestCase { public var $testSubgroup: FlagGroupWigwag { FlagGroupWigwag( keyPath: _flagKeyPath.append(.automatic("test-subgroup")), - name: nil, + name: "Test Subgroup", description: "Test Flag Group", displayOption: .navigation, lookup: _flagLookup @@ -145,7 +145,7 @@ final class FlagGroupMacroTests: XCTestCase { var $testSubgroup: FlagGroupWigwag { FlagGroupWigwag( keyPath: _flagKeyPath.append(.automatic("test-subgroup")), - name: nil, + name: "Test Subgroup", description: "meow", displayOption: .hidden, lookup: _flagLookup @@ -179,7 +179,7 @@ final class FlagGroupMacroTests: XCTestCase { var $testSubgroup: FlagGroupWigwag { FlagGroupWigwag( keyPath: _flagKeyPath.append(.automatic("test-subgroup")), - name: nil, + name: "Test Subgroup", description: "meow", displayOption: .navigation, lookup: _flagLookup @@ -213,7 +213,7 @@ final class FlagGroupMacroTests: XCTestCase { var $testSubgroup: FlagGroupWigwag { FlagGroupWigwag( keyPath: _flagKeyPath.append(.automatic("test-subgroup")), - name: nil, + name: "Test Subgroup", description: "meow", displayOption: .section, lookup: _flagLookup @@ -249,7 +249,7 @@ final class FlagGroupMacroTests: XCTestCase { var $testSubgroup: FlagGroupWigwag { FlagGroupWigwag( keyPath: _flagKeyPath.append(.automatic("test-subgroup")), - name: nil, + name: "Test Subgroup", description: "meow", displayOption: .navigation, lookup: _flagLookup @@ -283,7 +283,7 @@ final class FlagGroupMacroTests: XCTestCase { var $testSubgroup: FlagGroupWigwag { FlagGroupWigwag( keyPath: _flagKeyPath.append(.automatic("test-subgroup")), - name: nil, + name: "Test Subgroup", description: "meow", displayOption: .navigation, lookup: _flagLookup @@ -320,7 +320,7 @@ final class FlagGroupMacroTests: XCTestCase { var $testSubgroup: FlagGroupWigwag { FlagGroupWigwag( keyPath: _flagKeyPath.append(.automatic("test-subgroup")), - name: nil, + name: "Test Subgroup", description: "meow", displayOption: .navigation, lookup: _flagLookup @@ -354,7 +354,7 @@ final class FlagGroupMacroTests: XCTestCase { var $testSubgroup: FlagGroupWigwag { FlagGroupWigwag( keyPath: _flagKeyPath.append(.kebabcase("test-subgroup")), - name: nil, + name: "Test Subgroup", description: "meow", displayOption: .navigation, lookup: _flagLookup @@ -388,7 +388,7 @@ final class FlagGroupMacroTests: XCTestCase { var $testSubgroup: FlagGroupWigwag { FlagGroupWigwag( keyPath: _flagKeyPath.append(.snakecase("test_subgroup")), - name: nil, + name: "Test Subgroup", description: "meow", displayOption: .navigation, lookup: _flagLookup @@ -422,7 +422,7 @@ final class FlagGroupMacroTests: XCTestCase { var $testSubgroup: FlagGroupWigwag { FlagGroupWigwag( keyPath: _flagKeyPath, - name: nil, + name: "Test Subgroup", description: "meow", displayOption: .navigation, lookup: _flagLookup @@ -456,7 +456,7 @@ final class FlagGroupMacroTests: XCTestCase { var $testSubgroup: FlagGroupWigwag { FlagGroupWigwag( keyPath: _flagKeyPath.append(.customKey("test")), - name: nil, + name: "Test Subgroup", description: "meow", displayOption: .navigation, lookup: _flagLookup diff --git a/Tests/VexilMacroTests/FlagMacroTests.swift b/Tests/VexilMacroTests/FlagMacroTests.swift index 7a7dc606..31a1755e 100644 --- a/Tests/VexilMacroTests/FlagMacroTests.swift +++ b/Tests/VexilMacroTests/FlagMacroTests.swift @@ -42,7 +42,7 @@ final class FlagMacroTests: XCTestCase { var $testProperty: FlagWigwag { FlagWigwag( keyPath: _flagKeyPath.append(.automatic("test-property")), - name: nil, + name: "Test Property", defaultValue: false, description: "meow", displayOption: .default, @@ -77,7 +77,7 @@ final class FlagMacroTests: XCTestCase { var $testProperty: FlagWigwag { FlagWigwag( keyPath: _flagKeyPath.append(.automatic("test-property")), - name: nil, + name: "Test Property", defaultValue: 123.456, description: "meow", displayOption: .default, @@ -112,7 +112,7 @@ final class FlagMacroTests: XCTestCase { var $testProperty: FlagWigwag { FlagWigwag( keyPath: _flagKeyPath.append(.automatic("test-property")), - name: nil, + name: "Test Property", defaultValue: "alpha", description: "meow", displayOption: .default, @@ -147,7 +147,7 @@ final class FlagMacroTests: XCTestCase { var $testProperty: FlagWigwag { FlagWigwag( keyPath: _flagKeyPath.append(.automatic("test-property")), - name: nil, + name: "Test Property", defaultValue: .testCase, description: "meow", displayOption: .default, @@ -182,7 +182,7 @@ final class FlagMacroTests: XCTestCase { var $testProperty: FlagWigwag { FlagWigwag( keyPath: _flagKeyPath.append(.automatic("test-property")), - name: nil, + name: "Test Property", defaultValue: nil, description: "meow", displayOption: .default, @@ -217,7 +217,7 @@ final class FlagMacroTests: XCTestCase { public var $testProperty: FlagWigwag { FlagWigwag( keyPath: _flagKeyPath.append(.automatic("test-property")), - name: nil, + name: "Test Property", defaultValue: false, description: "meow", displayOption: .default, @@ -255,7 +255,7 @@ final class FlagMacroTests: XCTestCase { var $testProperty: FlagWigwag { FlagWigwag( keyPath: _flagKeyPath.append(.automatic("test-property")), - name: nil, + name: "Test Property", defaultValue: false, description: "meow", displayOption: .default, @@ -290,7 +290,7 @@ final class FlagMacroTests: XCTestCase { var $testProperty: FlagWigwag { FlagWigwag( keyPath: _flagKeyPath.append(.automatic("test-property")), - name: nil, + name: "Test Property", defaultValue: 123.456, description: "meow", displayOption: .default, @@ -325,7 +325,7 @@ final class FlagMacroTests: XCTestCase { var $testProperty: FlagWigwag { FlagWigwag( keyPath: _flagKeyPath.append(.automatic("test-property")), - name: nil, + name: "Test Property", defaultValue: "alpha", description: "meow", displayOption: .default, @@ -360,7 +360,7 @@ final class FlagMacroTests: XCTestCase { var $testProperty: FlagWigwag { FlagWigwag( keyPath: _flagKeyPath.append(.automatic("test-property")), - name: nil, + name: "Test Property", defaultValue: SomeEnum.testCase, description: "meow", displayOption: .default, @@ -506,7 +506,7 @@ final class FlagMacroTests: XCTestCase { var $testProperty: FlagWigwag { FlagWigwag( keyPath: _flagKeyPath.append(.automatic("test-property")), - name: nil, + name: "Test Property", defaultValue: false, description: "meow", displayOption: .default, @@ -541,7 +541,7 @@ final class FlagMacroTests: XCTestCase { var $testProperty: FlagWigwag { FlagWigwag( keyPath: _flagKeyPath.append(.automatic("test-property")), - name: nil, + name: "Test Property", defaultValue: false, description: "meow", displayOption: .default, @@ -579,7 +579,7 @@ final class FlagMacroTests: XCTestCase { var $testProperty: FlagWigwag { FlagWigwag( keyPath: _flagKeyPath.append(.automatic("test-property")), - name: nil, + name: "Test Property", defaultValue: false, description: "meow", displayOption: .default, @@ -614,7 +614,7 @@ final class FlagMacroTests: XCTestCase { var $testProperty: FlagWigwag { FlagWigwag( keyPath: _flagKeyPath.append(.kebabcase("test-property")), - name: nil, + name: "Test Property", defaultValue: false, description: "meow", displayOption: .default, @@ -649,7 +649,7 @@ final class FlagMacroTests: XCTestCase { var $testProperty: FlagWigwag { FlagWigwag( keyPath: _flagKeyPath.append(.snakecase("test_property")), - name: nil, + name: "Test Property", defaultValue: false, description: "meow", displayOption: .default, @@ -684,7 +684,7 @@ final class FlagMacroTests: XCTestCase { var $testProperty: FlagWigwag { FlagWigwag( keyPath: _flagKeyPath.append(.customKey("test")), - name: nil, + name: "Test Property", defaultValue: false, description: "meow", displayOption: .default, @@ -719,7 +719,7 @@ final class FlagMacroTests: XCTestCase { var $testProperty: FlagWigwag { FlagWigwag( keyPath: FlagKeyPath("test", separator: _flagKeyPath.separator, strategy: _flagKeyPath.strategy), - name: nil, + name: "Test Property", defaultValue: false, description: "meow", displayOption: .default, diff --git a/Tests/VexilTests/FlagDetailTests.swift b/Tests/VexilTests/FlagDetailTests.swift index 747cd98f..026ae1e5 100644 --- a/Tests/VexilTests/FlagDetailTests.swift +++ b/Tests/VexilTests/FlagDetailTests.swift @@ -22,7 +22,7 @@ struct FlagDetailTests { let pole = FlagPole(hoist: TestFlags.self, sources: []) #expect(pole.$topLevelFlag.key == "top-level-flag") - #expect(pole.$topLevelFlag.name == nil) + #expect(pole.$topLevelFlag.name == "Top Level Flag") #expect(pole.$topLevelFlag.description == "Top level test flag") #expect(pole.$secondTestFlag.key == "second-test-flag") @@ -30,7 +30,7 @@ struct FlagDetailTests { #expect(pole.$secondTestFlag.description == "Second test flag") #expect(pole.subgroup.$secondLevelFlag.key == "subgroup.second-level-flag") - #expect(pole.subgroup.$secondLevelFlag.name == nil) + #expect(pole.subgroup.$secondLevelFlag.name == "Second Level Flag") #expect(pole.subgroup.$secondLevelFlag.description == "Second Level Flag") #expect(pole.subgroup.$secondLevelFlag.displayOption == .hidden)