From 8a339d611679e897f1cba38c9ef62bd98107fba4 Mon Sep 17 00:00:00 2001 From: a dinosaur Date: Thu, 21 Nov 2024 10:58:36 +1100 Subject: [PATCH] Sort search results by name and version --- Sources/apk/Common/ApkVersionCompare.swift | 121 +++++++++++ Sources/apk/Common/ApkVersionReader.swift | 200 ++++++++++++++++++ .../Subcommands/DpkSearchCommand.swift | 16 +- 3 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 Sources/apk/Common/ApkVersionCompare.swift create mode 100644 Sources/apk/Common/ApkVersionReader.swift diff --git a/Sources/apk/Common/ApkVersionCompare.swift b/Sources/apk/Common/ApkVersionCompare.swift new file mode 100644 index 0000000..fd2e1c7 --- /dev/null +++ b/Sources/apk/Common/ApkVersionCompare.swift @@ -0,0 +1,121 @@ +/* + * darwin-apk © 2024 Gay Pizza Specifications + * SPDX-License-Identifier: Apache-2.0 + */ + +import Darwin + +public struct ApkVersionCompare { + public static func validate(_ version: String) -> Bool { + var reader = ApkVersionReader(version[...]) + while true { + switch try? reader.next() { + case .end: return true + case nil: return false + default: continue + } + } + } + + public static func compare(_ a: String, _ b: String, mode: Mode = .normal) -> Comparison? { + if (a.isEmpty && b.isEmpty) || a == b { + return .equal + } + + var readA = ApkVersionReader(a[...]), readB = ApkVersionReader(b[...]) + var tokenA: ApkVersionReader.TokenPart, tokenB: ApkVersionReader.TokenPart + do { + while true { + (tokenA, tokenB) = (try readA.next(), try readB.next()) + guard let c = ApkVersionReader.TokenPart.compare(tokenA, tokenB) else { + break + } + if c != .equal { + return c + } + } + } catch { + return nil + } + + // Both versions are equal if they're the same length or we are fuzzy matching prefixes + if tokenA == tokenB || (mode == .fuzzy && tokenB == .end) { + return .equal + } + + // Mark non-prerelease versions as greater than the same version marked prerelease + if case .suffix(let suffix) = tokenA, [ .alpha, .beta, .pre, .rc ].contains(suffix) { + return .less + } else if case .suffix(let suffix) = tokenB, [ .alpha, .beta, .pre, .rc ].contains(suffix) { + return .greater + } else { + return ApkVersionReader.TokenPart.compValue(tokenB, tokenA) + } + } +} + +public extension ApkVersionCompare { + enum Comparison { + case less, greater, equal + } + + enum Mode { + case normal, fuzzy + } +} + +//MARK: - Comparison implementation + +fileprivate extension ApkVersionReader.TokenPart { + static func compare(_ a: Self, _ b: Self) -> ApkVersionCompare.Comparison? { + switch a { + case .digit(let lhsNumber, let lhsString): + guard case .digit(let rhsNumber, let rhsString) = b else { + return nil + } + // If either are digit & zero prefixed & not initial then handle as string + return if lhsString?.first == "0" || rhsString?.first == "0" { + self.compValue(lhsString!, rhsString!) + } else { + Self.compValue(lhsNumber, rhsNumber) + } + case .letter(let lhs): + guard case .letter(let rhs) = b else { + return nil + } + return Self.compValue(lhs.isASCII ? UInt(lhs.asciiValue!) : 0, rhs.isASCII ? UInt(rhs.asciiValue!) : 0) + case .suffixNumber(let lhs): + return if case .suffixNumber(let rhs) = b { Self.compValue(lhs, rhs) } else { nil } + case .revision(let lhs): + return if case .revision(let rhs) = b { Self.compValue(lhs, rhs) } else { nil } + case .commitHash(let lhs): + return if case .commitHash(let rhs) = b { Self.compValue(lhs, rhs) } else { nil } + case .suffix(let lhs): + return if case .suffix(let rhs) = b { Self.compValue(lhs.rawValue, rhs.rawValue) } else { nil } + case .end: + return nil + } + } + + //MARK: - Private comparison implementation + + static func compValue(_ a: T, _ b: T) -> ApkVersionCompare.Comparison { + if a < b { .less } + else if a == b { .equal } + else { .greater } + } + + private static func compValue(_ a: T, _ b: T) -> ApkVersionCompare.Comparison { + let minLength = min(a.utf8.count, b.utf8.count) + let comparison = a.withCString { ca in + b.withCString { cb in + memcmp(ca, cb, minLength) + } + } + if comparison != 0 { + return comparison < 0 ? .less : .greater + } else { + return Self.compValue(a.utf8.count, b.utf8.count) + } + } +} diff --git a/Sources/apk/Common/ApkVersionReader.swift b/Sources/apk/Common/ApkVersionReader.swift new file mode 100644 index 0000000..58b7bab --- /dev/null +++ b/Sources/apk/Common/ApkVersionReader.swift @@ -0,0 +1,200 @@ +/* + * darwin-apk © 2024 Gay Pizza Specifications + * SPDX-License-Identifier: Apache-2.0 + */ + +internal struct ApkVersionReader { + var string: Substring + private var seen: TokenFlag, last: TokenFlag + + init(_ string: Substring) { + self.string = string + self.seen = [] + self.last = [] + } + + mutating func next() throws(Invalid) -> TokenPart { + self.seen.formUnion(self.last) + + switch string.first ?? "\0" { + case "a"..."z": // Letter suffix + guard self.seen.contains(.initial), + self.last.isDisjoint(with: [ .letter, .suffix, .suffixNumber, .commitHash, .revision ]) else { + throw .invalid + } + self.last = .letter + return .letter(self.advance()) + case ".": // Version separator + guard self.seen.contains(.initial), self.last.contains(.digit) else { + throw .invalid + } + self.advance() + fallthrough + case "0"..."9": // Numeric component + guard self.last.isSubset(of: [ .initial, .digit, .suffix ]), + let (number, numString) = self.readNumber() else { + throw .invalid + } + if self.last == .suffix { + self.last = .suffixNumber + return .suffixNumber(number) + } else { + self.last = .digit + if !self.seen.contains(.initial) { + self.last.insert(.initial) + return .digit(number, nil) + } else { + // Numeric digits that aren't initial might be compared as a string instead + return .digit(number, numString) + } + } + case "_": // Suffix + guard self.seen.contains(.initial), self.seen.isDisjoint(with: [ .commitHash, .revision ]) else { + throw .invalid + } + self.advance() + guard let suffix = self.readVersionSuffix() else { + throw .invalid + } + self.last = .suffix + return .suffix(suffix) + case "~": // Commit hash + guard self.seen.contains(.initial), self.seen.isDisjoint(with: [ .commitHash, .revision ]) else { + throw .invalid + } + self.advance() + let end = self.string.firstIndex(where: { !$0.isHexDigit }) ?? self.string.endIndex + let hex = self.advance(end) + guard self.string.isEmpty else { // Commit hash should take the rest of string + throw .invalid + } + self.last = .commitHash + return .commitHash(hex) + case "-": // Package revision + guard self.seen.contains(.initial), self.seen.isDisjoint(with: .revision), + self.advance(2) == "-r", + let (number, _) = self.readNumber() else { + throw .invalid + } + self .last = .revision + return .revision(number) + case "\0": // End of version string + guard self.seen.contains(.initial) else { + throw .invalid + } + return .end + default: + throw .invalid + } + } + + //MARK: - Private Implementation + + private mutating func readNumber() -> (UInt, Substring)? { + // Hacky and awful but seems to be the fastest way to get numeric token length + let digits = self.string.withCString { + var i = 0 + while 48...57 ~= $0[i] { // isnumber(Int32($0[i])) != 0 + i += 1 + } + return i + } + let end = self.string.index(self.string.startIndex, offsetBy: digits) + let string = self.string[.. VersionSuffix? { + let end = self.string.firstIndex(where: { !$0.isLowercase }) ?? self.string.endIndex + let suffix = self.advance(end) + return switch suffix.first { // TODO: Should this matching be stricter? + case "a": .alpha + case "b": .beta + case "c": .cvs + case "g": .git + case "h": .hg + case "p": suffix.count == 1 ? .p : .pre + case "r": .rc + case "s": .svn + default: nil + } + } + + @discardableResult + private mutating func advance(_ next: String.Index) -> Substring { + defer { + self.string = self.string[next...] + } + return self.string[.. Character { + defer { + self.string = string[string.index(after: string.startIndex)...] + } + return self.string[self.string.startIndex] + } + + private mutating func advance(_ len: Int) -> Substring { + self.advance(self.string.index(self.string.startIndex, offsetBy: len)) + } +} + +extension ApkVersionReader { + private struct TokenFlag: OptionSet { + let rawValue: UInt8 + + static let initial = Self(rawValue: 1 << 0) + static let digit = Self(rawValue: 1 << 1) + static let letter = Self(rawValue: 1 << 2) + static let suffix = Self(rawValue: 1 << 3) + static let suffixNumber = Self(rawValue: 1 << 4) + static let commitHash = Self(rawValue: 1 << 5) + static let revision = Self(rawValue: 1 << 6) + } + + enum TokenPart { + case digit(_ number: UInt, _ string: Substring?) + case letter(_ char: Character) + case suffix(_ suffix: VersionSuffix) + case suffixNumber(_ number: UInt) + case commitHash(_ hash: Substring) + case revision(_ number: UInt) + case end + } + + enum VersionSuffix: Int { + case alpha = 0, beta = 1, cvs = 5, git = 7, hg = 8, pre = 2, p = 9, rc = 3, svn = 6 + } + + enum Invalid: Error { + case invalid + } +} + +extension ApkVersionReader.TokenPart: Comparable { + @inlinable var order: Int { + switch self { + case .digit: 1 + case .letter: 2 + case .suffix: 3 + case .suffixNumber: 4 + case .commitHash: 5 + case .revision: 6 + case .end: 7 + } + } + + @inlinable static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.order == rhs.order + } + + @inlinable static func < (lhs: Self, rhs: Self) -> Bool { + return lhs.order < rhs.order + } +} diff --git a/Sources/dpk-cli/Subcommands/DpkSearchCommand.swift b/Sources/dpk-cli/Subcommands/DpkSearchCommand.swift index 1cbb8dd..d24e50c 100644 --- a/Sources/dpk-cli/Subcommands/DpkSearchCommand.swift +++ b/Sources/dpk-cli/Subcommands/DpkSearchCommand.swift @@ -50,10 +50,24 @@ struct DpkSearchCommand: AsyncParsableCommand { throw .failure } + var results = [ApkIndexPackage]() for package in index.packages { if match.match(package.name) || (!self.nameOnly && match.match(package.packageDescription)) { - print(package.shortDescription) + results.append(package) } } + + results.sort { lhs, rhs in + switch lhs.name.localizedCaseInsensitiveCompare(rhs.name) { + case .orderedSame: + ApkVersionCompare.compare(lhs.version, rhs.version) == .greater + case .orderedDescending: false + case .orderedAscending: true + } + } + + for package in results { + print(package.shortDescription) + } } }