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 .github/workflows/swift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ on:

jobs:
run-tests:
runs-on: macos-14
runs-on: macos-latest

steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
- name: Run tests
run: swift test --explicit-target-dependency-import-check error
10 changes: 7 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
// swift-tools-version: 5.9
// swift-tools-version: 6.0

import PackageDescription
import Foundation

let package = Package(
name: "LocalizeChecker",
platforms: [.macOS(.v14)],
platforms: [.macOS(.v15)],
products: [
.executable(name: "check-localize", targets: ["LocalizeCheckerCLI"]),
.library(name: "LocalizeChecker", targets: ["LocalizeChecker"])
Expand All @@ -28,6 +28,9 @@ let package = Package(
.product(name: "SwiftSyntax", package: "swift-syntax"),
.product(name: "SwiftParser", package: "swift-syntax"),
"LocalizeChecker",
],
swiftSettings: [
.enableUpcomingFeature("AccessLevelOnImport")
]
),
.target(
Expand All @@ -46,5 +49,6 @@ let package = Package(
path: "Tests/LocalizeChecker",
resources: [.copy("Fixtures")]
)
]
],
swiftLanguageModes: [.v5, .v6]
)
2 changes: 1 addition & 1 deletion Sources/LocalizeChecker/Checker/ErrorMessage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Foundation
import SwiftSyntax

/// Contains all necessary meta data to locate and describe localization check error
public struct ErrorMessage: Equatable, Codable {
public struct ErrorMessage: Equatable, Codable, Sendable {
/// Key of the localized string in the dictionary
public let key: String

Expand Down
65 changes: 52 additions & 13 deletions Sources/LocalizeChecker/Checker/SourceFileBatchChecker.swift
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
import Foundation

/// Performs multiple checks at once considering certain optimizations depending on the amount of them
public final class SourceFileBatchChecker {
public actor SourceFileBatchChecker {

public typealias ReportStream = AsyncThrowingStream<ErrorMessage, Error>

public typealias UnusedKeysStream = AsyncThrowingStream<UnusedKeyMessage, Error>
typealias ReportMessages = (errors: [ErrorMessage], unused: [UnusedKeyMessage], used: [LocalizeEntry])

@available(macOS 12, *)
/// Async stream of obtained check reports
public var reports: ReportStream {
get throws {
try run()
}
}

@available(macOS, deprecated: 12, obsoleted: 13, message: "Use much faster reports stream")
public func getReports() throws -> [ErrorMessage] {
try syncRun()

public var unusedKeys: [UnusedKeyMessage] {
get async throws {
try await runForUnusedKeys()
}
}

@available(macOS 12, *)
Expand Down Expand Up @@ -60,20 +63,21 @@ public final class SourceFileBatchChecker {
@discardableResult
func run() throws -> ReportStream {
let localizeBundle = try LocalizeBundle(directoryPath: localizeBundleUrl.path)
let chunks = chunks
return ReportStream { continuation in
Task {
await withThrowingTaskGroup(of: [ErrorMessage].self) { group in
await withThrowingTaskGroup(of: ReportMessages.self) { group in
for filesChunk in chunks {
group.addTask {
try self.processBatch(
try await self.processBatch(
ofSourceFiles: Array(filesChunk),
in: localizeBundle
)
}
}

do {
for try await reportsChunk in group {
for try await (reportsChunk, _, _) in group {
reportsChunk.forEach {
continuation.yield($0)
}
Expand All @@ -86,9 +90,40 @@ public final class SourceFileBatchChecker {
}
}
}


@available(macOS 12, *)
@discardableResult
func runForUnusedKeys() async throws -> [UnusedKeyMessage] {
let localizeBundle = try LocalizeBundle(directoryPath: localizeBundleUrl.path)
return try await Task {
try await withThrowingTaskGroup(of: ReportMessages.self) { group in
for filesChunk in chunks {
group.addTask {
try await self.processBatch(
ofSourceFiles: Array(filesChunk),
in: localizeBundle
)
}
}

var usedKeys: Set<String> = []
var unusedKeys: Set<String> = []
for try await (_, unusedKeysChunk, usedKeysChunk) in group {
unusedKeysChunk.forEach {
unusedKeys.insert($0.key)
}
usedKeysChunk.forEach {
usedKeys.insert($0.key)
}
}
let trulyUnusedKeys = unusedKeys.subtracting(usedKeys)
return Array(trulyUnusedKeys.map(UnusedKeyMessage.init(key:)))
}
}.value
}

@discardableResult
func syncRun() throws -> [ErrorMessage] {
func syncRun() throws -> ReportMessages {
let localizeBundle = LocalizeBundle(fileUrl: localizeBundleUrl)
let reports = try self.processBatch(
ofSourceFiles: sourceFiles,
Expand All @@ -98,7 +133,7 @@ public final class SourceFileBatchChecker {
return reports
}

private func processBatch(ofSourceFiles files: [String], in localizeBundle: LocalizeBundle) throws -> [ErrorMessage] {
private func processBatch(ofSourceFiles files: [String], in localizeBundle: LocalizeBundle) throws -> ReportMessages {
let fileUrls = files.compactMap(URL.init(fileURLWithPath:))
let sourceCheckers = try fileUrls.map {
try SourceFileChecker(fileUrl: $0, localizeBundle: localizeBundle)
Expand All @@ -107,7 +142,11 @@ public final class SourceFileBatchChecker {
try sourceChecker.start()
}

return sourceCheckers.flatMap(\.errors)
return (
sourceCheckers.flatMap(\.errors),
sourceCheckers.flatMap(\.unusedKeys).map(UnusedKeyMessage.init(key:)),
sourceCheckers.flatMap(\.usedKeys)
)
}

}
10 changes: 8 additions & 2 deletions Sources/LocalizeChecker/Checker/SourceFileChecker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import SwiftParser
final class SourceFileChecker {

var errors: [ErrorMessage] = []
var usedKeys: [LocalizeEntry] = []
var unusedKeys: [String] = []

private let fileUrl: URL
private let bundle: LocalizeBundle
Expand All @@ -22,22 +24,26 @@ final class SourceFileChecker {
func start() throws {
guard try fastCheck() else { return }

let syntaxTree = Parser.parse(source: try String(contentsOf: fileUrl))
let syntaxTree = Parser.parse(source: try String(contentsOf: fileUrl, encoding: .utf8))
let converter = SourceLocationConverter(fileName: fileUrl.path, tree: syntaxTree)
let parser = LocalizeParser(converter: converter)

parser.walk(syntaxTree)
errors = parser.foundKeys
.filter(notExistsInBundle)
.compactMap(\.errorMessage)
usedKeys = parser.foundKeys
unusedKeys = bundle.keys.filter { key in
!parser.foundKeys.contains(where: { $0.key == key })
}
}

}

private extension SourceFileChecker {

func fastCheck() throws -> Bool {
try String(contentsOf: fileUrl).contains(".\(literalMarker)")
try String(contentsOf: fileUrl, encoding: .utf8).contains(".\(literalMarker)")
}

}
Expand Down
6 changes: 6 additions & 0 deletions Sources/LocalizeChecker/Checker/UnusedKey.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Foundation

public struct UnusedKeyMessage: Equatable, Hashable, Codable, Sendable {
/// Key of the localized string in the dictionary
public let key: String
}
26 changes: 16 additions & 10 deletions Sources/LocalizeChecker/Data source/LocalizeBundle.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import Synchronization

enum Localizable: String {
case strings = "Localizable.strings"
Expand All @@ -7,17 +8,17 @@ enum Localizable: String {

/// Represents a merged bundle structure for **Localizable.strings** and **Localizable.stringsdict**
/// Each key can be obtained by subscript
public final class LocalizeBundle {
public final class LocalizeBundle: Sendable {

typealias LocalizeHash = [String : Any]

private let dictionary: LocalizeHash
private nonisolated(unsafe) let dictionary: LocalizeHash

/// Create bundle from file
/// - Parameter fileUrl: URL of the strings file
public init(fileUrl: URL) {
dictionary = Self.parseStrings(fileUrl: fileUrl)

print("LocalizeBundle(fileUrl:): dict.count = \(dictionary.keys.count)")
}

Expand All @@ -28,7 +29,7 @@ public final class LocalizeBundle {
let fileManager = FileManager()
let items = try fileManager.contentsOfDirectory(atPath: directoryPath)

dictionary = try items.reduce(into: [:]) { accDict, item in
let dict: LocalizeHash = try items.reduce(into: [:]) { accDict, item in
let fileUrl = directoryUrl.appendingPathComponent(item)
switch Localizable(rawValue: item) {
case .strings:
Expand All @@ -44,22 +45,27 @@ public final class LocalizeBundle {
break
}
}

dictionary = dict

print("LocalizeBundle(directoryPath:): dict.count = \(dictionary.keys.count)")
}

public subscript(key: String) -> Any? {
dictionary[key]
}


public var keys: [String] {
Array(dictionary.keys)
}

}

// MARK:- Parsing

private extension LocalizeBundle {

static func parseStrings(fileUrl: URL) -> [String: String] {
let rawContent = try? String(contentsOf: fileUrl)
let rawContent = try? String(contentsOf: fileUrl, encoding: .utf8)
return rawContent.map(Self.parseStrings) ?? [:]
}

Expand Down Expand Up @@ -94,7 +100,7 @@ private extension LocalizeBundle {

extension LocalizeBundle: ExpressibleByStringLiteral {

convenience public init(stringLiteral string: String) {
public convenience init(stringLiteral string: String) {
self.init(fileUrl: URL(fileURLWithPath: string))
}

Expand Down
4 changes: 2 additions & 2 deletions Sources/LocalizeChecker/Parser/LocalizeEntry.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Foundation
import SwiftSyntax
@preconcurrency import SwiftSyntax

struct LocalizeEntry {
struct LocalizeEntry: Hashable, Sendable {
let key: String
let sourceLocation: SourceLocation
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Foundation

/// Formats localization check error to the suitable format for Xcode
public struct XcodeReportFormatter: ReportFormatter {
public struct XcodeReportFormatter: ReportFormatter, Sendable {

private let strictlicity: ReportStrictlicity

Expand Down
1 change: 1 addition & 0 deletions Sources/LocalizeChecker/Reporter/ReportPrinter.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation

@MainActor
/// Prints checker reports in the given format
public final class ReportPrinter {

Expand Down
2 changes: 1 addition & 1 deletion Sources/LocalizeChecker/Reporter/ReportStrictlicity.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// Level of stritclicity used to output reports
/// **Available options**: error, warning
public enum ReportStrictlicity: String {
public enum ReportStrictlicity: String, Sendable {
case error
case warning
}
8 changes: 4 additions & 4 deletions Sources/LocalizeCheckerCLI/LocalizeCheckerCLI.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Foundation
import ArgumentParser
@preconcurrency import ArgumentParser
import LocalizeChecker

@main
Expand All @@ -16,7 +16,7 @@ struct LocalizeCheckerCLI: AsyncParsableCommandProtocol, SourceFilesTraversalTra
@Option(help: "Level of panic on invalid keys usage: (error | warning). `error` is default")
var strictlicity: ReportStrictlicity?

static var configuration = CommandConfiguration(
static let configuration = CommandConfiguration(
commandName: "check-localize",
abstract: "Scans for misused localization keys in your project sources",
version: "0.1.2"
Expand All @@ -29,13 +29,13 @@ struct LocalizeCheckerCLI: AsyncParsableCommandProtocol, SourceFilesTraversalTra
sourceFiles: try files,
localizeBundleFile: localizeBundleFile
)
let reportPrinter = ReportPrinter(
let reportPrinter = await ReportPrinter(
formatter: XcodeReportFormatter(strictlicity: strictlicity ?? .error)
)

let start = ProcessInfo.processInfo.systemUptime

for try await report in try checker.reports {
for try await report in try await checker.reports {
await reportPrinter.print(report)
}

Expand Down
4 changes: 2 additions & 2 deletions Tests/LocalizeChecker/LocalizeParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ extension LocalizeParserTests {
func testFoundLocalizedString() throws {
// GIVEN
setup(input: inputSource)
let parsed = Parser.parse(source: try String(contentsOf: fileUrl))
let parsed = Parser.parse(source: try String(contentsOf: fileUrl, encoding: .utf8))
let converter = SourceLocationConverter(fileName: fileUrl.path, tree: parsed)
let checker = LocalizeParser(converter: converter)

Expand All @@ -50,7 +50,7 @@ extension LocalizeParserTests {
func testUsualStringLiteralNotTreatedAsLocalizedString() throws {
// GIVEN
setup(input: inputSource1)
let parsed = Parser.parse(source: try String(contentsOf: fileUrl))
let parsed = Parser.parse(source: try String(contentsOf: fileUrl, encoding: .utf8))
let converter = SourceLocationConverter(fileName: fileUrl.path, tree: parsed)
let checker = LocalizeParser(converter: converter)

Expand Down
Loading