Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ let package = Package(
.macOS(.v13),
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"),
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.5.0"),
.package(url: "https://github.com/artem-y/swifty-test-assertions.git", from: "0.1.1"),
],
targets: [
Expand Down
28 changes: 23 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,38 @@ A tool that finds Xcode color assets by their hex codes. The idea behind this to
### ⚠️Disclaimer:
For now, the tool only supports searching for exact values of color components as ints, floats or hexadecimals, without conversion between settings like content type (sRGB, Display P3, Gray Gamma 2.2 etc.), and ignoring some other settings like Gamut etc.
## Usage
The tool can be used like this from terminal:
By default, the tool can be used from the terminal to search for matches to a given color code:
```
hexcode #ffa500
```
...where `#ffa500` is a hex color code, with or without `#`, case-insensitive.

This way `hexcode` will recursively search for the color assets matching the hex rgb value, starting from current directory. The output will be one or more matching color set names, or a message notifying that it haven't found an asset with the given color. The command also has some very simple error handling and might exit with error.
When used this way, `hexcode` will recursively search for the color assets matching the hex rgb value, starting from the current directory. The output will be one or more matching color set names, or a message in case it haven't found an asset with the given color. Color names include path to their color asset, relative to the project. The command also has some very simple error handling and might exit with error.
More arguments and options will be added in the future with new features, they can be found using the `--help` flag.
#### Examples
#### Default usage examples
Color found:
<img width="570" alt="hexcode_usage_color_found" src="https://github.com/artem-y/hexcode/assets/52959979/708ea8d5-b38a-4c69-813b-a987a28d4242">
<img width="568" alt="hexcode_usage_color_found_1" src="https://github.com/user-attachments/assets/5a25b195-c981-4048-8b4b-e17b8df10a25" />

Color not found:
<img width="570" alt="hexcode_usage_no_such_color" src="https://github.com/artem-y/hexcode/assets/52959979/77a36d4c-9480-4603-9ae2-8a6bce410a4e">
<img width="570" alt="hexcode_usage_no_such_color" src="https://github.com/artem-y/hexcode/assets/52959979/77a36d4c-9480-4603-9ae2-8a6bce410a4e">

### Find Duplicates
Hexcode can also check a project or a directory for duplicated color assets.
```zsh
hexcode find-duplicates
```
Output example when there are duplicates:
```
#24658F MyProject/Assets.xcassets/AccentColor
#24658F MyProject/Colors.xcassets/defaultAccent
--
#999999 MyProject/Assets.xcassets/appColor/gray
#999999 MyProject/Colors.xcassets/neutralGray
```
Output when duplicates not found:
```
No duplicates found
```

## Installation
1. Clone the repository to your machine
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import ArgumentParser

@main
struct Hexcode: AsyncParsableCommand {
struct FindColor: AsyncParsableCommand {

static let configuration = CommandConfiguration(
commandName: "hexcode",
commandName: "find-color",
abstract: """
hexcode is a tool that finds Xcode color assets \
Default subcommand that finds Xcode color assets \
by their hexadecimal codes.
""",
version: "hexcode 0.1.1"
"""
)

@Argument
Expand All @@ -29,6 +27,6 @@ struct Hexcode: AsyncParsableCommand {
}

func run() async throws {
try await HexcodeApp().run(colorHex: colorHex, in: directory)
try await HexcodeApp().runFindColor(colorHex: colorHex, in: directory)
}
}
15 changes: 15 additions & 0 deletions Sources/hexcode/Commands/FindDuplicates.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import ArgumentParser

struct FindDuplicates: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "find-duplicates",
abstract: "Finds duplicate Xcode color assets."
)

@Option
var directory: String?

func run() async throws {
try await HexcodeApp().runFindDuplicates(in: directory)
}
}
20 changes: 20 additions & 0 deletions Sources/hexcode/Commands/Hexcode.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import ArgumentParser

@main
struct Hexcode: AsyncParsableCommand {

static let configuration = CommandConfiguration(
commandName: "hexcode",
abstract: """
hexcode is a tool that finds Xcode color assets \
by their hexadecimal codes.
""",
usage: "hexcode <color-hex> [--directory <directory>]",
version: "hexcode 0.2.0",
subcommands: [
FindColor.self,
FindDuplicates.self,
],
defaultSubcommand: FindColor.self
)
}
45 changes: 26 additions & 19 deletions Sources/hexcode/Controllers/AssetCollector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ final class AssetCollector: AssetCollecting {
}

let paths = try fileManager.contentsOfDirectory(atPath: directory)
let namedColorSets = await self.findColorSets(at: paths.map { "\(directory)/\($0)"})
let namedColorSets = await self.findColorSets(
at: paths.map { "\(directory)/\($0)"},
in: directory
)
return namedColorSets.sorted(by: { $0.name < $1.name })
}
}
Expand All @@ -51,6 +54,7 @@ extension AssetCollector {
extension AssetCollector {
private func findColorSets(
at paths: [String],
in searchRootDirectory: String,
alreadyFoundColorSets: [NamedColorSet] = []
) async -> [NamedColorSet] {
let colorSets = await withTaskGroup(of: [NamedColorSet].self) { group in
Expand All @@ -60,13 +64,18 @@ extension AssetCollector {

switch contentAtPath {
case .colorSet(let colorSet):
return self.makeNamedColorset(from: colorSet, at: path)
return self.makeNamedColorset(
from: colorSet,
at: path,
in: searchRootDirectory
)

case .otherDirectory(let subpaths):
guard !subpaths.isEmpty else { return [] }
let fullSubpaths = subpaths.map { "\(path)/\($0)" }
let colorSetsFromSubdirectory = await self.findColorSets(
at: fullSubpaths,
in: searchRootDirectory,
alreadyFoundColorSets: alreadyFoundColorSets
)
return colorSetsFromSubdirectory
Expand All @@ -83,16 +92,21 @@ extension AssetCollector {
return colorSets
}

private func makeNamedColorset(from colorSet: ColorSet, at path: String) -> [NamedColorSet] {
let assetName = getAssetName(from: path)
private func makeNamedColorset(
from colorSet: ColorSet,
at path: String,
in searchRootDirectory: String
) -> [NamedColorSet] {

let assetName = getAssetName(from: path, in: searchRootDirectory)
let namedColorSet = NamedColorSet(name: assetName, colorSet: colorSet)
return [namedColorSet]
}

private func determineContentType(at path: String) -> PathContentType? {
var isDirectory: ObjCBool = false
guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory) else { return nil }
guard isDirectory.boolValue else { return .file }
guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory),
isDirectory.boolValue else { return .file }

if !path.hasSuffix(".colorset"), let subpaths = try? contents(at: path) {
return .otherDirectory(subpaths: subpaths)
Expand All @@ -105,24 +119,17 @@ extension AssetCollector {
try fileManager.contentsOfDirectory(atPath: directory)
}

private func getAssetName(from path: String) -> String {
makeURL(from: path)
private func getAssetName(from path: String, in searchRootDirectory: String) -> String {
let trimmedPath = String(path.trimmingPrefix(searchRootDirectory + "/"))
return URL(filePath: trimmedPath)
.deletingPathExtension()
.lastPathComponent
}

private func makeURL(from path: String) -> URL {
if #available(macOS 13.0, *) {
return URL(filePath: path)
} else {
return URL(fileURLWithPath: path)
}
.relativeString
}

private func readColorSet(at path: String) -> ColorSet? {
let path = path + "/Contents.json"
guard let fileData = fileManager.contents(atPath: path) else { return nil }
guard let colorSet = try? JSONDecoder().decode(ColorSet.self, from: fileData) else { return nil }
guard let fileData = fileManager.contents(atPath: path),
let colorSet = try? JSONDecoder().decode(ColorSet.self, from: fileData) else { return nil }
return colorSet
}
}
77 changes: 74 additions & 3 deletions Sources/hexcode/Controllers/ColorFinder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ protocol ColorFinding {
/// - parameter colorSets: Color sets to check for matching hex color code.
/// - returns: Names of color sets with matching colors. Empty if none found.
func find(_ hex: String, in colorSets: [NamedColorSet]) -> [String]


/// Searches the collection of named color sets for colors matching the same hex equivalent.
/// - Parameter colorSets: Color sets to check for duplicate hex values.
/// - Returns: Hexadecimal color codes with arrays of matching color duplicates. Empty if none found.
func findDuplicates(in colorSets: [NamedColorSet]) -> [String: [String]]
}

final class ColorFinder: ColorFinding {
Expand All @@ -21,12 +27,69 @@ final class ColorFinder: ColorFinding {
guard !appearances.isEmpty else {
return nil
}
guard appearances.count < colors.count else {
return namedSet.name

return makeColorName(from: appearances, of: namedSet)
}
}

func findDuplicates(in colorSets: [NamedColorSet]) -> [String: [String]] {

var duplicates: [String: [String]] = [:]
let colorSetCount = colorSets.count

for currentColorSetIndex in 0..<colorSetCount {
let currentColorSet = colorSets[currentColorSetIndex]
let currentColors = currentColorSet.colorSet.colors
let nextColorSetIndex = currentColorSetIndex + 1

for currentColor in currentColors {
let rgbHex = currentColor.color.rgbHex

if rgbHex.isEmpty || duplicates.keys.contains(rgbHex) {
continue
}

var colorNames: [String] = []

for otherColorSet in colorSets[nextColorSetIndex...] {
let otherColorSetAppearances = findAppearances(
for: rgbHex,
in: otherColorSet.colorSet.colors
)

if otherColorSetAppearances.isEmpty {
continue
}

let name = makeColorName(
from: otherColorSetAppearances,
of: otherColorSet
)
colorNames.append(name)
}

// If at least one duplicate found, add the searched name too
if !colorNames.isEmpty {
let currentColorSetAppearances = findAppearances(
for: rgbHex,
in: currentColors
)

let currentColorSetName = makeColorName(
from: currentColorSetAppearances,
of: currentColorSet
)
colorNames.append(currentColorSetName)

if duplicates[rgbHex] == nil {
duplicates[rgbHex] = colorNames.sorted()
}
}

return namedSet.name + " (\(joined(appearances)))"
}
}

return duplicates
}
}

Expand Down Expand Up @@ -59,4 +122,12 @@ extension ColorFinder {
private func joined(_ appearances: [String]) -> String {
appearances.joined(separator: ", ")
}

private func makeColorName(from appearances: [String], of namedColorSet: NamedColorSet) -> String {
if appearances.count < namedColorSet.colorSet.colors.count {
return "\(namedColorSet.name) (\(joined(appearances)))"
} else {
return namedColorSet.name
}
}
}
42 changes: 38 additions & 4 deletions Sources/hexcode/HexcodeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ final class HexcodeApp {
self.assetCollector = assetCollector
}

/// Entry point for `hexcode` app logic.
/// - parameter colorHex: Raw input argument for hexadecimal color code.
/// - parameter directory: Optional custom directory from user input. Defaults to current directory.
/// Entry point for the default `find-color` subcommand logic.
/// - Parameters:
/// - colorHex: Raw input argument for hexadecimal color code.
/// - directory: Optional custom directory from user input. Defaults to current directory.
/// - throws: All unhandled errors that can be thrown out to standard output.
func run(colorHex: String, in directory: String? = nil) async throws {
func runFindColor(colorHex: String, in directory: String? = nil) async throws {
let directory = directory ?? fileManager.currentDirectoryPath
let colorAssets = try await assetCollector.collectAssets(in: directory)
let foundColors = colorFinder.find(colorHex, in: colorAssets)
Expand All @@ -37,4 +38,37 @@ final class HexcodeApp {

foundColors.forEach { output($0) }
}


/// Entry point for the `find-duplicates` subcommand logic.
/// - Parameter directory: Optional custom directory from user input. Defaults to current directory.
/// - throws: All unhandled errors that can be thrown out to standard output.
func runFindDuplicates(in directory: String? = nil) async throws {
let directory = directory ?? fileManager.currentDirectoryPath
let colorAssets = try await assetCollector.collectAssets(in: directory)
let foundDuplicates = colorFinder.findDuplicates(in: colorAssets)

if foundDuplicates.isEmpty {
output("No duplicates found")
return
}

var hasMoreThanOneDuplicate = false
foundDuplicates
.sorted { $0.key < $1.key }
.forEach { duplicate in

if hasMoreThanOneDuplicate {
output("--")
}

duplicate.value.forEach { color in
output("#\(duplicate.key) \(color)")
}

if !hasMoreThanOneDuplicate {
hasMoreThanOneDuplicate = true
}
}
}
}
Loading