diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 961f0ff..36deebd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: head "https://github.com/${{ github.repository }}.git" license "MIT" - depends_on :xcode => ["14.0", :build] + depends_on :xcode => ["12.0", :build] def install system "make", "install", "prefix=#{prefix}" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3b150ab..be00eaa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,7 +5,7 @@ on: jobs: BuildAndTest: - runs-on: macos-12 + runs-on: macos-latest steps: - uses: actions/checkout@v1 diff --git a/.swift-version b/.swift-version index 760606e..d346e2a 100644 --- a/.swift-version +++ b/.swift-version @@ -1 +1 @@ -5.7 +5.3 diff --git a/Docs/assetcatalog.png b/Docs/assetcatalog.png deleted file mode 100644 index 0340a76..0000000 Binary files a/Docs/assetcatalog.png and /dev/null differ diff --git a/Docs/html.png b/Docs/html.png deleted file mode 100644 index 46391a2..0000000 Binary files a/Docs/html.png and /dev/null differ diff --git a/Example/.gitignore b/Example/.gitignore deleted file mode 100644 index 5d1cee6..0000000 --- a/Example/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -Example.* -!Example.txt diff --git a/Example/Example.txt b/Example/Example.txt deleted file mode 100644 index c2be7ac..0000000 --- a/Example/Example.txt +++ /dev/null @@ -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 - diff --git a/Package.resolved b/Package.resolved index 717a74e..228d9c6 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,23 +1,16 @@ { - "pins" : [ - { - "identity" : "rbbjson", - "kind" : "remoteSourceControl", - "location" : "https://github.com/robb/RBBJSON", - "state" : { - "branch" : "main", - "revision" : "102c970283e105d7c5be2e29630db29c808c20eb" + "object": { + "pins": [ + { + "package": "swift-argument-parser", + "repositoryURL": "https://github.com/apple/swift-argument-parser", + "state": { + "branch": null, + "revision": "92646c0cdbaca076c8d3d0207891785b3379cbff", + "version": "0.3.1" + } } - }, - { - "identity" : "swift-argument-parser", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser", - "state" : { - "revision" : "9f39744e025c7d377987f30b03770805dcb0bcd1", - "version" : "1.1.4" - } - } - ], - "version" : 2 + ] + }, + "version": 1 } diff --git a/Package.swift b/Package.swift index 05a09d5..ea9aaaa 100644 --- a/Package.swift +++ b/Package.swift @@ -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. import PackageDescription @@ -6,25 +6,27 @@ import PackageDescription let package = Package( name: "MakeColors", platforms: [ - .macOS("12.0"), + .macOS(.v10_15), ], dependencies: [ - .package(url: "https://github.com/apple/swift-argument-parser", .upToNextMinor(from: "1.1.4")), - .package(url: "https://github.com/robb/RBBJSON", branch: "main"), + .package(url: "https://github.com/apple/swift-argument-parser", .upToNextMinor(from: "0.3.1")), ], targets: [ - .executableTarget( + .target( name: "MakeColors", + dependencies: [ + "LibMakeColors", + ] + ), + .target( + name: "LibMakeColors", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), ] ), .testTarget( name: "MakeColorsTests", - dependencies: [ - "MakeColors", - .product(name: "RBBJSON", package: "RBBJSON"), - ] + dependencies: ["LibMakeColors"] ), ] ) diff --git a/README.md b/README.md index b9b8980..1dda36b 100644 --- a/README.md +++ b/README.md @@ -11,61 +11,21 @@ brew tap 5sw/makecolors brew install make-colors ``` -If you don’t use Homebrew you can also install directly from source. Clone the repository or download the release and run `make install` inside the working copy. - -## Usage - -``` -USAGE: make-colors [--ios] [--android] [--html] [--prefix ] [--output ] [--dump] - -ARGUMENTS: - The color list to process. - Use - to process the standard input. - -OPTIONS: - --ios/--android/--html The formatter to use. (default: ios) - --prefix Prefix for color names. - --output Output file to write. - Use - for standard output. - Default is the input file name with the appropriate file extension. If - the input is - the default is standard output. - Note that asset catalogs cannot be written to standard output. - --dump List read colors on console. - -h, --help Show help information. -``` - ## Input format Each line in your input contains one color definition. That is a name followed by the actual color. We support RGB colors in a few formats similar to CSS: ``` -Base/Green #8fd151 -Base/PaleGreen #d0f9a9 -Base/Red rgb(249, 39, 7) -TransparentRed rgba(255, 0, 0, 128) -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. - -``` -Black white(0) -MediumGray white(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) +Color/Color1 #fff +Color/Color2 #abcdeff0 +Color/Color3 rgb(12, 13, 53) +Color/Color4 rgba(250, 250, 250, 128) ``` Colors can also reference other colors by prefixing them with an `@` sign: ``` -Error @Base/Red -Warning @Base/Yellow -Good @Base/Green +ColorAlias @Color/Color1 ``` ## Output format @@ -74,41 +34,17 @@ Good @Base/Green The optional prefix followed by a `/` is added in front of the color name. Then for each part separate by / a new folder that provides namespace is inserted in the asset catalogs. Spaces are inserted between CamelCase words. Color references are inserted as copies of the original color. -For the given example input the generated asset catalog looks like this: - -![](Docs/assetcatalog.png) - ### Android resource XML (`--android`) The optional prefix, followed by a underscore is added in front of the name. Names are converted from CamelCase to snake_case and / is replaced by underscores as well. Color references the generated color resource also references the original color. -The generated XML for the example input looks like this: - -``` - - - #8FD151 - #D0F9A9 - #F92707 - #FFFF00 - #FF000080 - @color/base_red - @color/base_green - @color/base_yellow - - -``` - ### HTML preview (`--html`) Generates a simple HTML table with the color names, values and a sample. -The generated HTML looks like this: - -![](Docs/html.png) - ## Future work +- Support other color formats (HSV, ...) - Calculate derived colors (blend, change hue/saturation/brightness/alpha) - Support for dark/light mode - Improved error reporting in the parser diff --git a/Sources/MakeColors/Extensions/FileWrapper+Extensions.swift b/Sources/LibMakeColors/Extensions/FileWrapper+Extensions.swift similarity index 100% rename from Sources/MakeColors/Extensions/FileWrapper+Extensions.swift rename to Sources/LibMakeColors/Extensions/FileWrapper+Extensions.swift diff --git a/Sources/MakeColors/Extensions/StringProtocol+Extensions.swift b/Sources/LibMakeColors/Extensions/StringProtocol+Extensions.swift similarity index 100% rename from Sources/MakeColors/Extensions/StringProtocol+Extensions.swift rename to Sources/LibMakeColors/Extensions/StringProtocol+Extensions.swift diff --git a/Sources/MakeColors/Generators/AndroidGenerator.swift b/Sources/LibMakeColors/Generators/AndroidGenerator.swift similarity index 100% rename from Sources/MakeColors/Generators/AndroidGenerator.swift rename to Sources/LibMakeColors/Generators/AndroidGenerator.swift diff --git a/Sources/MakeColors/Generators/AssetCatalogGenerator.swift b/Sources/LibMakeColors/Generators/AssetCatalogGenerator.swift similarity index 76% rename from Sources/MakeColors/Generators/AssetCatalogGenerator.swift rename to Sources/LibMakeColors/Generators/AssetCatalogGenerator.swift index f21d153..7851575 100644 --- a/Sources/MakeColors/Generators/AssetCatalogGenerator.swift +++ b/Sources/LibMakeColors/Generators/AssetCatalogGenerator.swift @@ -1,7 +1,7 @@ import Foundation final class AssetCatalogGenerator: Generator { - static let defaultExtension = "xcassets" + static let defaultExtension = "xcasset" static let option = "ios" let context: Context @@ -58,7 +58,7 @@ private let infoTag = """ } """ -extension Color { +private extension Color { func json() -> String { """ { @@ -67,10 +67,10 @@ extension Color { "color" : { "color-space" : "srgb", "components" : { - "alpha" : "\(Double(alpha) / 0xFF)", - "blue" : "0x\(blue, radix: 16, width: 2)", - "green" : "0x\(green, radix: 16, width: 2)", - "red" : "0x\(red, radix: 16, width: 2)" + "alpha" : "\(Float(alpha) / 256)", + "blue" : "0x\(String(blue, radix: 16))", + "green" : "0x\(String(green, radix: 16))", + "red" : "0x\(String(red, radix: 16))" } }, "idiom" : "universal" @@ -88,22 +88,6 @@ extension Color { } } -extension String.StringInterpolation { - mutating func appendInterpolation( - _ 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 = """ { "properties" : { diff --git a/Sources/MakeColors/Generators/Generator.swift b/Sources/LibMakeColors/Generators/Generator.swift similarity index 85% rename from Sources/MakeColors/Generators/Generator.swift rename to Sources/LibMakeColors/Generators/Generator.swift index 085021b..8561ac3 100644 --- a/Sources/MakeColors/Generators/Generator.swift +++ b/Sources/LibMakeColors/Generators/Generator.swift @@ -1,6 +1,6 @@ import Foundation -protocol Generator: AnyObject { +protocol Generator: class { static var defaultExtension: String { get } static var option: String { get } @@ -9,7 +9,7 @@ protocol Generator: AnyObject { func generate(data: [String: ColorDef]) throws -> FileWrapper } -protocol Context: AnyObject { +protocol Context: class { var prefix: String? { get } } diff --git a/Sources/MakeColors/Generators/HTMLGenerator.swift b/Sources/LibMakeColors/Generators/HTMLGenerator.swift similarity index 100% rename from Sources/MakeColors/Generators/HTMLGenerator.swift rename to Sources/LibMakeColors/Generators/HTMLGenerator.swift diff --git a/Sources/LibMakeColors/MakeColors.swift b/Sources/LibMakeColors/MakeColors.swift new file mode 100644 index 0000000..8dfa743 --- /dev/null +++ b/Sources/LibMakeColors/MakeColors.swift @@ -0,0 +1,83 @@ +import ArgumentParser +import Foundation + +private struct GeneratorOption: EnumerableFlag, CustomStringConvertible { + static let allCases: [GeneratorOption] = [ + .init(type: AssetCatalogGenerator.self), + .init(type: AndroidGenerator.self), + .init(type: HTMLGenerator.self), + ] + + let type: Generator.Type + + var description: String { + type.option + } + + static func == (lhs: GeneratorOption, rhs: GeneratorOption) -> Bool { + lhs.type == rhs.type + } +} + +enum Errors: Error { + case syntaxError + case duplicateColor(String) + case missingReference(String) + case cyclicReference(String) +} + +public final class MakeColors: ParsableCommand, Context { + @Argument(help: "The color list to proces") + var input: String + + @Flag(help: "The formatter to use") + private var formatter = GeneratorOption.allCases[0] + + @Option(help: "Prefix for color names") + var prefix: String? + + @Option(help: "Output file") + var output: String? + + public init() {} + + public func run() throws { + let url = URL(fileURLWithPath: input) + let string = try String(contentsOf: url) + + let scanner = Scanner(string: string) + scanner.charactersToBeSkipped = .whitespaces + + let data = try scanner.colorList() + + for (key, color) in data.sorted() { + let resolved = try data.resolve(key) + switch color { + case .color: + print(key.insertCamelCaseSeparators(), resolved, separator: ": ") + + case let .reference(referenced): + print( + "\(key.insertCamelCaseSeparators()) (@\(referenced.insertCamelCaseSeparators()))", + resolved, + separator: ": " + ) + } + } + + let generator = formatter.type.init(context: self) + let fileWrapper = try generator.generate(data: data) + + let writeURL = outputURL(extension: formatter.type.defaultExtension) + try fileWrapper.write(to: writeURL, options: .atomic, originalContentsURL: 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`) + } + } +} diff --git a/Sources/MakeColors/Model/Color.swift b/Sources/LibMakeColors/Model/Color.swift similarity index 89% rename from Sources/MakeColors/Model/Color.swift rename to Sources/LibMakeColors/Model/Color.swift index 6e9740d..ef2030e 100644 --- a/Sources/MakeColors/Model/Color.swift +++ b/Sources/LibMakeColors/Model/Color.swift @@ -19,14 +19,6 @@ struct Color: CustomStringConvertible, Equatable { alpha = array.count == 4 ? array[3] : 0xFF } - init(white array: [UInt8]) { - precondition(array.count == 1 || array.count == 2) - red = array[0] - green = array[0] - blue = array[0] - alpha = array.count == 2 ? array[1] : 0xFF - } - var description: String { let alphaSuffix = alpha != 0xFF ? String(format: "%02X", alpha) : "" return String(format: "#%02X%02X%02X%@", red, green, blue, alphaSuffix) diff --git a/Sources/MakeColors/Importers/List/Scanner+ColorParser.swift b/Sources/LibMakeColors/Model/Scanner+ColorParser.swift similarity index 55% rename from Sources/MakeColors/Importers/List/Scanner+ColorParser.swift rename to Sources/LibMakeColors/Model/Scanner+ColorParser.swift index b3d43d7..3d56246 100644 --- a/Sources/MakeColors/Importers/List/Scanner+ColorParser.swift +++ b/Sources/LibMakeColors/Model/Scanner+ColorParser.swift @@ -19,70 +19,17 @@ extension Scanner { } } - if string("rgba"), let components = argumentList(4) { + if string("rgba"), string("("), let components = commaSeparated(), components.count == 4, string(")") { return Color(components) } - if string("rgb"), let components = argumentList(3) { + if string("rgb"), string("("), let components = commaSeparated(), components.count == 3, string(")") { return Color(components) } - if string("white"), let arguments = argumentList(min: 1, max: 2) { - return Color(white: arguments) - } - - if string("hsva") { - return readHSV(alpha: true) - } - - if string("hsv") { - return readHSV(alpha: false) - } - 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? { guard string("@") else { return nil } return name() @@ -110,7 +57,7 @@ extension Scanner { func colorLine() -> (String, ColorDef)? { guard - let name = name(), + let name = self.name(), let def = colorDef(), endOfLine() else { @@ -144,50 +91,17 @@ extension Scanner { return result } - // swiftlint:disable:next discouraged_optional_collection - func argumentList(_ count: Int) -> [UInt8]? { - argumentList(min: count, max: count) - } - - // swiftlint:disable:next discouraged_optional_collection - func argumentList(min: Int, max: Int? = nil) -> [UInt8]? { - let max = max ?? Int.max - guard - string("("), - let arguments = commaSeparated(), - string(")"), - (min...max) ~= arguments.count - else { - return nil - } - - return arguments - } - // swiftlint:disable:next discouraged_optional_collection func commaSeparated() -> [UInt8]? { var result: [UInt8] = [] repeat { - guard let component = component() else { + guard let int = scanInt(), let component = UInt8(exactly: int) else { return nil } result.append(component) } while string(",") 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 { @@ -197,7 +111,7 @@ private extension Scanner { } private extension CharacterSet { - static let hex = CharacterSet(charactersIn: "0123456789abcdefABCDEF") + static let hex = CharacterSet(charactersIn: "0123456789abcdef") static let name = alphanumerics.union(CharacterSet(charactersIn: "_/")) } diff --git a/Sources/MakeColors/Importers/Figma/FigmaImporter.swift b/Sources/MakeColors/Importers/Figma/FigmaImporter.swift deleted file mode 100644 index c84339f..0000000 --- a/Sources/MakeColors/Importers/Figma/FigmaImporter.swift +++ /dev/null @@ -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.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)) - } -} diff --git a/Sources/MakeColors/Importers/Importer.swift b/Sources/MakeColors/Importers/Importer.swift deleted file mode 100644 index 6c4d5d8..0000000 --- a/Sources/MakeColors/Importers/Importer.swift +++ /dev/null @@ -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() - } -} diff --git a/Sources/MakeColors/Importers/List/ListImporter.swift b/Sources/MakeColors/Importers/List/ListImporter.swift deleted file mode 100644 index ef291b1..0000000 --- a/Sources/MakeColors/Importers/List/ListImporter.swift +++ /dev/null @@ -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 - } -} diff --git a/Sources/MakeColors/MakeColors.swift b/Sources/MakeColors/MakeColors.swift deleted file mode 100644 index 8b88849..0000000 --- a/Sources/MakeColors/MakeColors.swift +++ /dev/null @@ -1,150 +0,0 @@ -import ArgumentParser -import Foundation - -private struct GeneratorOption: EnumerableFlag, CustomStringConvertible { - static let allCases: [GeneratorOption] = [ - .init(type: AssetCatalogGenerator.self), - .init(type: AndroidGenerator.self), - .init(type: HTMLGenerator.self), - ] - - let type: Generator.Type - - var description: String { - type.option - } - - static func == (lhs: GeneratorOption, rhs: GeneratorOption) -> Bool { - lhs.type == rhs.type - } -} - -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 { - case syntaxError - case duplicateColor(String) - case missingReference(String) - case cyclicReference(String) - case cannotWriteWrapperToStdout - case cannotReadStdin -} - -enum HelpTexts { - static let input = ArgumentHelp( - "The color list to process.", - discussion: """ - Use - to process the standard input. - """ - ) - - static let output = ArgumentHelp( - "Output file to write.", - discussion: """ - Use - for standard output. - Default is the input file name with the appropriate file extension. \ - If the input is - the default is standard output. - Note that asset catalogs cannot be written to standard output. - """ - ) -} - -@main -public final class MakeColors: AsyncParsableCommand, Context { - @Argument(help: HelpTexts.input) - var input: String - - @Flag(help: "The formatter to use.") - private var formatter = GeneratorOption.allCases[0] - - @Option(help: "The importer to use.") - private var importer = ImporterOption.list - - @Option(help: "Prefix for color names.") - var prefix: String? - - @Option(help: HelpTexts.output) - var output: String? - - @Flag(help: "List read colors on console.") - var dump = false - - public init() {} - - public func run() async throws { - let importer = try importer.type.init(source: input) - let data = try await importer.read() - - if dump { - try dump(data: data) - } - - let generator = formatter.type.init(context: self) - let fileWrapper = try generator.generate(data: data) - - try writeOutput(fileWrapper, name: output ?? "\(importer.outputName).\(formatter.type.defaultExtension)") - } - - func dump(data: [String: ColorDef]) throws { - for (key, color) in data.sorted() { - let resolved = try data.resolve(key) - switch color { - case .color: - print(key.insertCamelCaseSeparators(), resolved, separator: ": ") - - case let .reference(referenced): - print( - "\(key.insertCamelCaseSeparators()) (@\(referenced.insertCamelCaseSeparators()))", - resolved, - separator: ": " - ) - } - } - } - - func writeOutput(_ wrapper: FileWrapper, name: String) throws { - if shouldWriteToStdout { - guard wrapper.isRegularFile, let contents = wrapper.regularFileContents else { - throw Errors.cannotWriteWrapperToStdout - } - - FileHandle.standardOutput.write(contents) - } else { - let writeURL = URL(fileURLWithPath: name) - try wrapper.write(to: writeURL, options: .atomic, originalContentsURL: nil) - } - } - - var shouldWriteToStdout: Bool { output == "-" || (input == "-" && output == nil) } -} diff --git a/Sources/MakeColors/Model/Color+HSV.swift b/Sources/MakeColors/Model/Color+HSV.swift deleted file mode 100644 index 9251c03..0000000 --- a/Sources/MakeColors/Model/Color+HSV.swift +++ /dev/null @@ -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 - ) - } -} diff --git a/Sources/MakeColors/main.swift b/Sources/MakeColors/main.swift new file mode 100644 index 0000000..db25b90 --- /dev/null +++ b/Sources/MakeColors/main.swift @@ -0,0 +1,3 @@ +import LibMakeColors + +MakeColors.main() diff --git a/Tests/MakeColorsTests/AssetCatalogFormattingTest.swift b/Tests/MakeColorsTests/AssetCatalogFormattingTest.swift deleted file mode 100644 index d4f9f10..0000000 --- a/Tests/MakeColorsTests/AssetCatalogFormattingTest.swift +++ /dev/null @@ -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")) - } -} diff --git a/Tests/MakeColorsTests/ColorHSVTest.swift b/Tests/MakeColorsTests/ColorHSVTest.swift deleted file mode 100644 index 9eba183..0000000 --- a/Tests/MakeColorsTests/ColorHSVTest.swift +++ /dev/null @@ -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)) - } -} diff --git a/Tests/MakeColorsTests/ColorParserTest.swift b/Tests/MakeColorsTests/ColorParserTest.swift index 8c3756e..86e8e4d 100644 --- a/Tests/MakeColorsTests/ColorParserTest.swift +++ b/Tests/MakeColorsTests/ColorParserTest.swift @@ -1,123 +1,40 @@ -@testable import MakeColors +@testable import LibMakeColors import XCTest final class ColorParserTest: XCTestCase { func testScanningThreeDigitColor() throws { - let color = scanColor("#abc") - XCTAssertEqual(Color(red: 0xAA, green: 0xBB, blue: 0xCC), color) - } - - func testScanningThreeDigitColorUppercase() throws { - let color = scanColor("#ABc") + let scanner = Scanner(string: "#abc") + let color = scanner.color() XCTAssertEqual(Color(red: 0xAA, green: 0xBB, blue: 0xCC), color) } func testScanningFourDigitColor() throws { - let color = scanColor("#abcd") + let scanner = Scanner(string: "#abcd") + let color = scanner.color() XCTAssertEqual(Color(red: 0xAA, green: 0xBB, blue: 0xCC, alpha: 0xDD), color) } func testScanningSixDigitColor() throws { - let color = scanColor("#abcdef") + let scanner = Scanner(string: "#abcdef") + let color = scanner.color() XCTAssertEqual(Color(red: 0xAB, green: 0xCD, blue: 0xEF), color) } func testScanningEightDigitColor() throws { - let color = scanColor("#abcdef17") + let scanner = Scanner(string: "#abcdef17") + let color = scanner.color() XCTAssertEqual(Color(red: 0xAB, green: 0xCD, blue: 0xEF, alpha: 0x17), color) } func testScanningRGBColor() throws { - let color = scanColor("rgb(1,2,3)") + let scanner = Scanner(string: "rgb(1,2,3)") + let color = scanner.color() XCTAssertEqual(Color(red: 1, green: 2, blue: 3), color) } func testScanningRGBAColor() throws { - let color = scanColor("rgba(1,2,3,4)") + let scanner = Scanner(string: "rgba(1,2,3,4)") + let color = scanner.color() XCTAssertEqual(Color(red: 1, green: 2, blue: 3, alpha: 4), color) } - - func testScanningWhite() throws { - let color = scanColor("white(255)") - XCTAssertEqual(Color(red: 255, green: 255, blue: 255, alpha: 255), color) - } - - func testScanningWhiteWithAlpha() throws { - let color = scanColor("white(255, 128)") - XCTAssertEqual(Color(red: 255, green: 255, blue: 255, alpha: 128), color) - } - - func testWhiteFailsWithoutArguments() throws { - let color = scanColor("white()") - XCTAssertNil(color) - } - - func testWhiteFailsWith3Arguments() throws { - let color = scanColor("white(1,2,3)") - 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? { - let scanner = Scanner(string: input) - let result = scanner.color() - XCTAssertTrue(result == nil || scanner.isAtEnd) - return result - } }