Compare commits

..

No commits in common. "main" and "v0.2.0" have entirely different histories.
main ... v0.2.0

26 changed files with 82 additions and 559 deletions

View file

@ -24,7 +24,7 @@ jobs:
head "https://github.com/${{ github.repository }}.git" head "https://github.com/${{ github.repository }}.git"
license "MIT" license "MIT"
depends_on :xcode => ["14.0", :build] depends_on :xcode => ["12.0", :build]
def install def install
system "make", "install", "prefix=#{prefix}" system "make", "install", "prefix=#{prefix}"

View file

@ -5,7 +5,7 @@ on:
jobs: jobs:
BuildAndTest: BuildAndTest:
runs-on: macos-12 runs-on: macos-latest
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1

View file

@ -1 +1 @@
5.7 5.3

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

2
Example/.gitignore vendored
View file

@ -1,2 +0,0 @@
Example.*
!Example.txt

View file

@ -1,10 +0,0 @@
Base/Green #8fd151
Base/PaleGreen #d0f9a9
Base/Red rgb(249, 39, 7)
TransparentRed rgba(255, 0, 0, 128)
Base/Yellow #ff0
Error @Base/Red
Warning @Base/Yellow
Good @Base/Green

View file

@ -1,23 +1,16 @@
{ {
"pins" : [ "object": {
"pins": [
{ {
"identity" : "rbbjson", "package": "swift-argument-parser",
"kind" : "remoteSourceControl", "repositoryURL": "https://github.com/apple/swift-argument-parser",
"location" : "https://github.com/robb/RBBJSON", "state": {
"state" : { "branch": null,
"branch" : "main", "revision": "92646c0cdbaca076c8d3d0207891785b3379cbff",
"revision" : "102c970283e105d7c5be2e29630db29c808c20eb" "version": "0.3.1"
} }
}
]
}, },
{ "version": 1
"identity" : "swift-argument-parser",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser",
"state" : {
"revision" : "9f39744e025c7d377987f30b03770805dcb0bcd1",
"version" : "1.1.4"
}
}
],
"version" : 2
} }

View file

@ -1,4 +1,4 @@
// swift-tools-version:5.7 // swift-tools-version:5.3
// The swift-tools-version declares the minimum version of Swift required to build this package. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
@ -6,25 +6,27 @@ import PackageDescription
let package = Package( let package = Package(
name: "MakeColors", name: "MakeColors",
platforms: [ platforms: [
.macOS("12.0"), .macOS("10.15.4"),
], ],
dependencies: [ dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser", .upToNextMinor(from: "1.1.4")), .package(url: "https://github.com/apple/swift-argument-parser", .upToNextMinor(from: "0.3.1")),
.package(url: "https://github.com/robb/RBBJSON", branch: "main"),
], ],
targets: [ targets: [
.executableTarget( .target(
name: "MakeColors", name: "MakeColors",
dependencies: [
"LibMakeColors",
]
),
.target(
name: "LibMakeColors",
dependencies: [ dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "ArgumentParser", package: "swift-argument-parser"),
] ]
), ),
.testTarget( .testTarget(
name: "MakeColorsTests", name: "MakeColorsTests",
dependencies: [ dependencies: ["LibMakeColors"]
"MakeColors",
.product(name: "RBBJSON", package: "RBBJSON"),
]
), ),
] ]
) )

View file

@ -45,7 +45,6 @@ Base/Red rgb(249, 39, 7)
TransparentRed rgba(255, 0, 0, 128) TransparentRed rgba(255, 0, 0, 128)
Base/Yellow #ff0 Base/Yellow #ff0
``` ```
Grayscale colors can be produced with the `white(value)` and `white(value, alpha)` syntax. A value of zero means black while a value of 255 is pure white. Grayscale colors can be produced with the `white(value)` and `white(value, alpha)` syntax. A value of zero means black while a value of 255 is pure white.
``` ```
@ -54,12 +53,6 @@ MediumGray white(128)
TransparentGray white(128, 128) TransparentGray white(128, 128)
``` ```
HSV colors can be specified as `hsv(hue, saturation, value)` or `hsva(hue, saturation, value, alpha)` syntax. Hue is specified as degrees with the `°` or `deg` suffix.
```
HSV/Yellow hsv(60°, 255, 255)
```
Colors can also reference other colors by prefixing them with an `@` sign: Colors can also reference other colors by prefixing them with an `@` sign:
``` ```
@ -109,6 +102,7 @@ The generated HTML looks like this:
## Future work ## Future work
- Support other color formats (HSV, ...)
- Calculate derived colors (blend, change hue/saturation/brightness/alpha) - Calculate derived colors (blend, change hue/saturation/brightness/alpha)
- Support for dark/light mode - Support for dark/light mode
- Improved error reporting in the parser - Improved error reporting in the parser

View file

@ -58,7 +58,7 @@ private let infoTag = """
} }
""" """
extension Color { private extension Color {
func json() -> String { func json() -> String {
""" """
{ {
@ -67,10 +67,10 @@ extension Color {
"color" : { "color" : {
"color-space" : "srgb", "color-space" : "srgb",
"components" : { "components" : {
"alpha" : "\(Double(alpha) / 0xFF)", "alpha" : "0x\(String(alpha, radix: 16))",
"blue" : "0x\(blue, radix: 16, width: 2)", "blue" : "0x\(String(blue, radix: 16))",
"green" : "0x\(green, radix: 16, width: 2)", "green" : "0x\(String(green, radix: 16))",
"red" : "0x\(red, radix: 16, width: 2)" "red" : "0x\(String(red, radix: 16))"
} }
}, },
"idiom" : "universal" "idiom" : "universal"
@ -88,22 +88,6 @@ extension Color {
} }
} }
extension String.StringInterpolation {
mutating func appendInterpolation<I: BinaryInteger>(
_ value: I,
radix: Int,
width: Int = 0,
uppercase: Bool = true
) {
var string = String(value, radix: radix, uppercase: uppercase)
if width > string.count {
string.insert(contentsOf: String(repeating: "0", count: width - string.count), at: string.startIndex)
}
appendLiteral(string)
}
}
private let group = """ private let group = """
{ {
"properties" : { "properties" : {

View file

@ -1,6 +1,6 @@
import Foundation import Foundation
protocol Generator: AnyObject { protocol Generator: class {
static var defaultExtension: String { get } static var defaultExtension: String { get }
static var option: String { get } static var option: String { get }
@ -9,7 +9,7 @@ protocol Generator: AnyObject {
func generate(data: [String: ColorDef]) throws -> FileWrapper func generate(data: [String: ColorDef]) throws -> FileWrapper
} }
protocol Context: AnyObject { protocol Context: class {
var prefix: String? { get } var prefix: String? { get }
} }

View file

@ -19,39 +19,6 @@ private struct GeneratorOption: EnumerableFlag, CustomStringConvertible {
} }
} }
private struct ImporterOption: CaseIterable, ExpressibleByArgument, CustomStringConvertible {
static let allCases: [ImporterOption] = [
.list,
.init(type: FigmaImporter.self),
]
static let list = ImporterOption(type: ListImporter.self)
let type: Importer.Type
init(type: Importer.Type) {
self.type = type
}
init?(argument: String) {
guard
let found = Self.allCases
.first(where: { $0.description.caseInsensitiveCompare(argument) == .orderedSame })
else {
return nil
}
self = found
}
var description: String {
type.option
}
static func == (lhs: ImporterOption, rhs: ImporterOption) -> Bool {
lhs.type == rhs.type
}
}
enum Errors: Error { enum Errors: Error {
case syntaxError case syntaxError
case duplicateColor(String) case duplicateColor(String)
@ -80,17 +47,13 @@ enum HelpTexts {
) )
} }
@main public final class MakeColors: ParsableCommand, Context {
public final class MakeColors: AsyncParsableCommand, Context {
@Argument(help: HelpTexts.input) @Argument(help: HelpTexts.input)
var input: String var input: String
@Flag(help: "The formatter to use.") @Flag(help: "The formatter to use.")
private var formatter = GeneratorOption.allCases[0] private var formatter = GeneratorOption.allCases[0]
@Option(help: "The importer to use.")
private var importer = ImporterOption.list
@Option(help: "Prefix for color names.") @Option(help: "Prefix for color names.")
var prefix: String? var prefix: String?
@ -102,9 +65,11 @@ public final class MakeColors: AsyncParsableCommand, Context {
public init() {} public init() {}
public func run() async throws { public func run() throws {
let importer = try importer.type.init(source: input) let scanner = Scanner(string: try readInput())
let data = try await importer.read() scanner.charactersToBeSkipped = .whitespaces
let data = try scanner.colorList()
if dump { if dump {
try dump(data: data) try dump(data: data)
@ -113,7 +78,27 @@ public final class MakeColors: AsyncParsableCommand, Context {
let generator = formatter.type.init(context: self) let generator = formatter.type.init(context: self)
let fileWrapper = try generator.generate(data: data) let fileWrapper = try generator.generate(data: data)
try writeOutput(fileWrapper, name: output ?? "\(importer.outputName).\(formatter.type.defaultExtension)") try writeOutput(fileWrapper)
}
func readInput() throws -> String {
if input == "-" {
return try readStdin()
}
let url = URL(fileURLWithPath: input)
return try String(contentsOf: url)
}
func readStdin() throws -> String {
guard
let data = try FileHandle.standardInput.readToEnd(),
let input = String(data: data, encoding: .utf8)
else {
throw Errors.cannotReadStdin
}
return input
} }
func dump(data: [String: ColorDef]) throws { func dump(data: [String: ColorDef]) throws {
@ -133,7 +118,7 @@ public final class MakeColors: AsyncParsableCommand, Context {
} }
} }
func writeOutput(_ wrapper: FileWrapper, name: String) throws { func writeOutput(_ wrapper: FileWrapper) throws {
if shouldWriteToStdout { if shouldWriteToStdout {
guard wrapper.isRegularFile, let contents = wrapper.regularFileContents else { guard wrapper.isRegularFile, let contents = wrapper.regularFileContents else {
throw Errors.cannotWriteWrapperToStdout throw Errors.cannotWriteWrapperToStdout
@ -141,10 +126,19 @@ public final class MakeColors: AsyncParsableCommand, Context {
FileHandle.standardOutput.write(contents) FileHandle.standardOutput.write(contents)
} else { } else {
let writeURL = URL(fileURLWithPath: name) let writeURL = outputURL(extension: formatter.type.defaultExtension)
try wrapper.write(to: writeURL, options: .atomic, originalContentsURL: nil) try wrapper.write(to: writeURL, options: .atomic, originalContentsURL: nil)
} }
} }
var shouldWriteToStdout: Bool { output == "-" || (input == "-" && output == nil) } var shouldWriteToStdout: Bool { output == "-" || (input == "-" && output == nil) }
func outputURL(extension: String) -> URL {
if let output = output {
return URL(fileURLWithPath: output)
} else {
let basename = URL(fileURLWithPath: input).deletingPathExtension().lastPathComponent
return URL(fileURLWithPath: basename).appendingPathExtension(`extension`)
}
}
} }

View file

@ -19,11 +19,11 @@ extension Scanner {
} }
} }
if string("rgba"), let components = argumentList(4) { if string("rgba"), string("("), let components = commaSeparated(), components.count == 4, string(")") {
return Color(components) return Color(components)
} }
if string("rgb"), let components = argumentList(3) { if string("rgb"), string("("), let components = commaSeparated(), components.count == 3, string(")") {
return Color(components) return Color(components)
} }
@ -31,58 +31,9 @@ extension Scanner {
return Color(white: arguments) return Color(white: arguments)
} }
if string("hsva") {
return readHSV(alpha: true)
}
if string("hsv") {
return readHSV(alpha: false)
}
return nil return nil
} }
private func readHSV(alpha readAlpha: Bool) -> Color? {
guard
string("("),
let hue = degrees(),
string(","),
let saturation = component(),
string(","),
let value = component()
else { return nil }
let alpha: UInt8
if readAlpha {
guard
string(","),
let value = component()
else { return nil }
alpha = value
} else {
alpha = 0xFF
}
guard string(")") else { return nil }
return Color(hue: hue, saturation: saturation, value: value, alpha: alpha)
}
func degrees() -> Int? {
guard let int = scanInt() else { return nil }
if string("%") {
guard 0...100 ~= int else { return nil }
return (360 * int) / 100
} else if string("°") || string("deg") {
return int
} else {
guard 0...0xFF ~= int else { return nil }
return (360 * int) / 0xFF
}
}
func colorReference() -> String? { func colorReference() -> String? {
guard string("@") else { return nil } guard string("@") else { return nil }
return name() return name()
@ -110,7 +61,7 @@ extension Scanner {
func colorLine() -> (String, ColorDef)? { func colorLine() -> (String, ColorDef)? {
guard guard
let name = name(), let name = self.name(),
let def = colorDef(), let def = colorDef(),
endOfLine() endOfLine()
else { else {
@ -168,26 +119,13 @@ extension Scanner {
func commaSeparated() -> [UInt8]? { func commaSeparated() -> [UInt8]? {
var result: [UInt8] = [] var result: [UInt8] = []
repeat { repeat {
guard let component = component() else { guard let int = scanInt(), let component = UInt8(exactly: int) else {
return nil return nil
} }
result.append(component) result.append(component)
} while string(",") } while string(",")
return result return result
} }
func component() -> UInt8? {
guard var int = scanInt() else {
return nil
}
if string("%") {
guard 0...100 ~= int else { return nil }
int = int * 0xFF / 100
}
return UInt8(exactly: int)
}
} }
private extension Scanner { private extension Scanner {

View file

@ -1,151 +0,0 @@
import Foundation
enum FigmaErrors: Error {
case invalidUrl
case missingToken
case invalidResponse
case missingColor(String)
}
class FigmaImporter: Importer {
let key: String
let token: String
let outputName: String
required init(source: String) throws {
// https://www.figma.com/file/:key/:title
guard
let url = URL(string: source),
url.host == "www.figma.com",
url.pathComponents.count >= 4,
url.pathComponents[1] == "file"
else {
throw FigmaErrors.invalidUrl
}
key = url.pathComponents[2]
outputName = url.pathComponents[3]
guard let token = ProcessInfo.processInfo.environment["FIGMA_TOKEN"] else {
throw FigmaErrors.missingToken
}
self.token = token
}
func read() async throws -> [String: ColorDef] {
let styles = try await request(StylesResponse.self, path: "/v1/files/\(key)/styles").meta.styles
.filter { $0.styleType == "FILL" }
let ids = styles.map(\.nodeId).joined(separator: ",")
let nodes = try await request(
NodesResponse.self,
path: "/v1/files/\(key)/nodes",
query: [URLQueryItem(name: "ids", value: ids)]
)
.nodes
var result: [String: ColorDef] = [:]
result.reserveCapacity(styles.count)
for style in styles {
guard
let node = nodes[style.nodeId],
let fill = node.document.fills.first(where: { $0.type == "SOLID" })
else {
throw FigmaErrors.missingColor(style.name)
}
if node.document.fills.count > 1 {
print("Warning: Multiple fills defined for \(style.name)")
}
if fill.blendMode != "NORMAL" {
print("Warning: Blend mode \(fill.blendMode) used for \(style.name)")
}
guard !result.keys.contains(style.name) else {
throw Errors.duplicateColor(style.name)
}
result[style.name] = .color(Color(fill.color))
}
return result
}
func request<T: Decodable>(_: T.Type = T.self, path: String, query: [URLQueryItem]? = nil) async throws -> T {
var components = URLComponents()
components.scheme = "https"
components.host = "api.figma.com"
components.path = path
components.queryItems = query
guard let url = components.url else {
fatalError("Cannot create url. Components: \(components)")
}
var request = URLRequest(url: url)
request.setValue(token, forHTTPHeaderField: "X-Figma-Token")
let (data, response) = try await URLSession.shared.data(for: request)
guard let response = response as? HTTPURLResponse else {
fatalError("Non-HTTP-Response received: \(response)")
}
guard response.statusCode == 200 else {
throw FigmaErrors.invalidResponse
}
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try decoder.decode(T.self, from: data)
}
}
struct StylesResponse: Decodable {
var meta: Meta
struct Meta: Decodable {
var styles: [Style]
}
struct Style: Decodable {
var nodeId: String
var styleType: String
var name: String
var description: String
}
}
struct NodesResponse: Decodable {
var nodes: [String: Node]
struct Node: Decodable {
var document: Document
}
struct Document: Decodable {
var fills: [Fill]
}
struct Fill: Decodable {
var blendMode: String
var type: String
var color: Color
}
struct Color: Decodable {
var r, g, b, a: Float
}
}
extension Color {
init(_ color: NodesResponse.Color) {
red = UInt8(truncatingIfNeeded: Int(color.r * 0xFF))
green = UInt8(truncatingIfNeeded: Int(color.g * 0xFF))
blue = UInt8(truncatingIfNeeded: Int(color.b * 0xFF))
alpha = UInt8(truncatingIfNeeded: Int(color.a * 0xFF))
}
}

View file

@ -1,15 +0,0 @@
protocol Importer {
init(source: String) throws
func read() async throws -> [String: ColorDef]
var outputName: String { get }
static var option: String { get }
}
extension Importer {
static var option: String {
String(describing: self).droppingSuffix("Importer").lowercased()
}
}

View file

@ -1,38 +0,0 @@
import Foundation
struct ListImporter: Importer {
let input: String
var outputName: String
init(source: String) {
input = source
outputName = URL(fileURLWithPath: source).deletingPathExtension().lastPathComponent
}
func read() throws -> [String: ColorDef] {
let scanner = Scanner(string: try readInput())
scanner.charactersToBeSkipped = .whitespaces
return try scanner.colorList()
}
func readInput() throws -> String {
if input == "-" {
return try readStdin()
}
let url = URL(fileURLWithPath: input)
return try String(contentsOf: url)
}
func readStdin() throws -> String {
guard
let data = try FileHandle.standardInput.readToEnd(),
let input = String(data: data, encoding: .utf8)
else {
throw Errors.cannotReadStdin
}
return input
}
}

View file

@ -1,45 +0,0 @@
extension Color {
init(hue: Int, saturation: UInt8, value: UInt8, alpha: UInt8 = 0xFF) {
let degrees = abs(hue % 360)
let saturation = Double(saturation) / 0xFF
let value = Double(value) / 0xFF
// swiftlint:disable identifier_name - Wish I knew what these actually mean.
let C = saturation * value
let X = C * (1 - abs((Double(degrees) / 60).truncatingRemainder(dividingBy: 2) - 1))
let m = value - C
// swiftlint:enable identifier_name
let result: (r: Double, g: Double, b: Double)
switch degrees {
case 0..<60:
result = (C, X, 0)
case 60..<120:
result = (X, C, 0)
case 120..<180:
result = (0, C, X)
case 180..<240:
result = (0, X, C)
case 240..<300:
result = (X, 0, C)
case 300..<360:
result = (C, 0, X)
default:
fatalError("Degrees out of range")
}
self.init(
red: UInt8(((result.r + m) * 0xFF).rounded()),
green: UInt8(((result.g + m) * 0xFF).rounded()),
blue: UInt8(((result.b + m) * 0xFF).rounded()),
alpha: alpha
)
}
}

View file

@ -0,0 +1,3 @@
import LibMakeColors
MakeColors.main()

View file

@ -1,26 +0,0 @@
@testable import MakeColors
import RBBJSON
import XCTest
class AssetCatalogFormattingTest: XCTestCase {
func testColorProducedValidJSON() throws {
let color = Color(red: 0xFF, green: 0xF0, blue: 0x0F)
let data = Data(color.json().utf8)
let json = try JSONDecoder().decode(RBBJSON.self, from: data)
XCTAssertEqual(json.info.author, .string("de.5sw.MakeColors"))
XCTAssertEqual(json.info.version, .number(1))
XCTAssertEqual(Array(json.colors[any: .child]).count, 1)
XCTAssertEqual(json.colors[0].idiom, .string("universal"))
let jsonColor = json.colors[0].color
XCTAssertEqual(jsonColor["color-space"], .string("srgb"))
XCTAssertEqual(jsonColor.components.alpha, .string("1.0"))
XCTAssertEqual(jsonColor.components.red, .string("0xFF"))
XCTAssertEqual(jsonColor.components.green, .string("0xF0"))
XCTAssertEqual(jsonColor.components.blue, .string("0x0F"))
}
}

View file

@ -1,39 +0,0 @@
@testable import MakeColors
import XCTest
final class ColorHSVTest: XCTestCase {
func testHSV0Degrees() {
let color = Color(hue: 0, saturation: 0xFF, value: 0xFF)
XCTAssertEqual(color, Color(red: 0xFF, green: 0, blue: 0))
}
func testHSV60Degrees() {
let color = Color(hue: 60, saturation: 0xFF, value: 0xFF)
XCTAssertEqual(color, Color(red: 0xFF, green: 0xFF, blue: 0))
}
func testHSV120Degrees() {
let color = Color(hue: 120, saturation: 0xFF, value: 0xFF)
XCTAssertEqual(color, Color(red: 0, green: 0xFF, blue: 0))
}
func testHSV180Degrees() {
let color = Color(hue: 180, saturation: 0xFF, value: 0xFF)
XCTAssertEqual(color, Color(red: 0, green: 0xFF, blue: 0xFF))
}
func testHSV240Degrees() {
let color = Color(hue: 240, saturation: 0xFF, value: 0xFF)
XCTAssertEqual(color, Color(red: 0, green: 0, blue: 0xFF))
}
func testHSV300Degrees() {
let color = Color(hue: 300, saturation: 0xFF, value: 0xFF)
XCTAssertEqual(color, Color(red: 0xFF, green: 0, blue: 0xFF))
}
func testHSV360Degrees() {
let color = Color(hue: 360, saturation: 0xFF, value: 0xFF)
XCTAssertEqual(color, Color(red: 0xFF, green: 0, blue: 0))
}
}

View file

@ -1,4 +1,4 @@
@testable import MakeColors @testable import LibMakeColors
import XCTest import XCTest
final class ColorParserTest: XCTestCase { final class ColorParserTest: XCTestCase {
@ -57,67 +57,8 @@ final class ColorParserTest: XCTestCase {
XCTAssertNil(color) XCTAssertNil(color)
} }
func testScanningColorWithPercentage() throws {
let color = scanColor("rgba(100%, 0, 50%, 100%)")
XCTAssertEqual(color, Color(red: 255, green: 0, blue: 127, alpha: 255))
}
func testReadingComponentAsByte() throws {
let scanner = Scanner(string: "128")
XCTAssertEqual(scanner.component(), 128)
XCTAssertTrue(scanner.isAtEnd)
}
func testReadingComponentAs100Percent() throws {
let scanner = Scanner(string: "100%")
XCTAssertEqual(scanner.component(), 0xFF)
XCTAssertTrue(scanner.isAtEnd)
}
func testReadingComponentAs0Percent() throws {
let scanner = Scanner(string: "0%")
XCTAssertEqual(scanner.component(), 0)
XCTAssertTrue(scanner.isAtEnd)
}
func testReadingComponentAs50PercentRoundsDown() throws {
let scanner = Scanner(string: "50%")
XCTAssertEqual(scanner.component(), 127)
XCTAssertTrue(scanner.isAtEnd)
}
func testScanningDegreesAsByte() throws {
let scanner = Scanner(string: "128")
XCTAssertEqual(scanner.degrees(), 180)
}
func testScanningDegreesAsPercentage() throws {
let scanner = Scanner(string: "50%")
XCTAssertEqual(scanner.degrees(), 180)
}
func testScanningDegrees() throws {
let scanner = Scanner(string: "120°")
XCTAssertEqual(scanner.degrees(), 120)
}
func testScanningDegreesWithDegSuffix() throws {
let scanner = Scanner(string: "120 deg")
XCTAssertEqual(scanner.degrees(), 120)
}
func testScanningHSVColor() throws {
XCTAssertEqual(scanColor("hsv(60°, 255, 100%)"), Color(red: 0xFF, green: 0xFF, blue: 0))
}
func testScanningHSVAColor() throws {
XCTAssertEqual(scanColor("hsva(60°, 50%, 255, 99)"), Color(red: 0xFF, green: 0xFF, blue: 128, alpha: 99))
}
private func scanColor(_ input: String) -> Color? { private func scanColor(_ input: String) -> Color? {
let scanner = Scanner(string: input) let scanner = Scanner(string: input)
let result = scanner.color() return scanner.color()
XCTAssertTrue(result == nil || scanner.isAtEnd)
return result
} }
} }