Sort search results by name and version

This commit is contained in:
2024-11-21 10:58:36 +11:00
parent c239b8e424
commit 8a339d6116
3 changed files with 336 additions and 1 deletions

View File

@ -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<T: Comparable>(_ a: T, _ b: T) -> ApkVersionCompare.Comparison {
if a < b { .less }
else if a == b { .equal }
else { .greater }
}
private static func compValue<T: StringProtocol>(_ 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)
}
}
}

View File

@ -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[..<end]
self.string = self.string[end...]
guard !string.isEmpty, let result = UInt(string, radix: 10) else {
return nil
}
return (result, string)
}
private mutating func readVersionSuffix() -> 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[..<next]
}
@discardableResult
private mutating func advance() -> 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
}
}

View File

@ -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)
}
}
}