Extract LineSort to package

This commit is contained in:
1024jp 2024-06-27 23:48:21 +09:00
parent e76d2995ca
commit f7f15f4c2f
12 changed files with 456 additions and 294 deletions

View File

@ -97,9 +97,8 @@
2A1856131D48AFEA008FA79E /* PrintPanelAccessoryController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1856111D48AFEA008FA79E /* PrintPanelAccessoryController.swift */; };
2A1893A71FFF16A400AD244F /* PatternSortView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1893A61FFF16A400AD244F /* PatternSortView.swift */; };
2A1893A81FFF16A400AD244F /* PatternSortView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1893A61FFF16A400AD244F /* PatternSortView.swift */; };
2A1893AA1FFF422D00AD244F /* LineSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1893A91FFF422D00AD244F /* LineSort.swift */; };
2A1893AB1FFF422D00AD244F /* LineSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1893A91FFF422D00AD244F /* LineSort.swift */; };
2A1893AD1FFF6A0100AD244F /* LineSortTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1893AC1FFF6A0100AD244F /* LineSortTests.swift */; };
2A1893AA1FFF422D00AD244F /* SortPatternError+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1893A91FFF422D00AD244F /* SortPatternError+Localization.swift */; };
2A1893AB1FFF422D00AD244F /* SortPatternError+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1893A91FFF422D00AD244F /* SortPatternError+Localization.swift */; };
2A19AF862AE0D15300EFFDCB /* FormPopUpButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A19AF852AE0D15300EFFDCB /* FormPopUpButton.swift */; };
2A19AF872AE0D15300EFFDCB /* FormPopUpButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A19AF852AE0D15300EFFDCB /* FormPopUpButton.swift */; };
2A1A4EB024FB9D9300B50AA0 /* Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1A4EAF24FB9D9300B50AA0 /* Combine.swift */; };
@ -845,8 +844,7 @@
2A18560A1D47FA37008FA79E /* TextFinder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextFinder.swift; sourceTree = "<group>"; };
2A1856111D48AFEA008FA79E /* PrintPanelAccessoryController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PrintPanelAccessoryController.swift; sourceTree = "<group>"; };
2A1893A61FFF16A400AD244F /* PatternSortView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatternSortView.swift; sourceTree = "<group>"; };
2A1893A91FFF422D00AD244F /* LineSort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineSort.swift; sourceTree = "<group>"; };
2A1893AC1FFF6A0100AD244F /* LineSortTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineSortTests.swift; sourceTree = "<group>"; };
2A1893A91FFF422D00AD244F /* SortPatternError+Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "SortPatternError+Localization.swift"; path = "../SortPatternError+Localization.swift"; sourceTree = "<group>"; };
2A18A5BC1C4A730D00BAD817 /* EncodingTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EncodingTests.swift; sourceTree = "<group>"; };
2A19AF852AE0D15300EFFDCB /* FormPopUpButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormPopUpButton.swift; sourceTree = "<group>"; };
2A1A4EAF24FB9D9300B50AA0 /* Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Combine.swift; sourceTree = "<group>"; };
@ -1770,7 +1768,6 @@
2ACF23AD26302A4C002B5E10 /* Theme+Syntax.swift */,
2A1E7DD32B8C5A23004F0C07 /* Mode.swift */,
2AB857EA2B93050E0079CFA2 /* ModeOptions.swift */,
2A1893A91FFF422D00AD244F /* LineSort.swift */,
2A341D19281EE23C00B85CB6 /* UserActivity.swift */,
2A55D5E92B7A86190092DE48 /* IssueReport.swift */,
);
@ -1998,6 +1995,7 @@
children = (
2A428D432C2B03670051AD4F /* ValueRange+Identifiable.swift */,
2AA7BDDA2C1B10C80075BB6C /* UnicodeNormalizationForm.swift */,
2A1893A91FFF422D00AD244F /* SortPatternError+Localization.swift */,
);
path = Libraries;
sourceTree = "<group>";
@ -2110,7 +2108,6 @@
2A9C07551CF9F982006D672D /* IncompatibleCharacterTests.swift */,
2A54BE2B1D40EB24000816B0 /* LineEndingTests.swift */,
2AED46721E43942300751C45 /* TextFindTests.swift */,
2A1893AC1FFF6A0100AD244F /* LineSortTests.swift */,
2AC72EA1253478D5001D3CA0 /* FileDropItemTests.swift */,
2ABEFB6923DC0CA0008769F4 /* EditorCounterTests.swift */,
2A1125C023F180FF006A1DB2 /* LineRangeCacheableTests.swift */,
@ -2781,7 +2778,7 @@
2A80BE8D27FFA61700D2F7FF /* LineEndingScanner.swift in Sources */,
2A6416A41D2F9F7200FA9E1A /* LineNumberView.swift in Sources */,
2A1125C423F1A86B006A1DB2 /* LineRangeCacheable.swift in Sources */,
2A1893AB1FFF422D00AD244F /* LineSort.swift in Sources */,
2A1893AB1FFF422D00AD244F /* SortPatternError+Localization.swift in Sources */,
2A59B7032957089A0094F03B /* LinkButton.swift in Sources */,
2AE144C42B0222DB005E8CF1 /* LiveTextInsertionView.swift in Sources */,
2A8961931DB76A3400E9E0EC /* MainMenu.swift in Sources */,
@ -2961,7 +2958,6 @@
2A80BE9227FFFA8900D2F7FF /* LineEndingScannerTests.swift in Sources */,
2A54BE2C1D40EB24000816B0 /* LineEndingTests.swift in Sources */,
2A1125C123F180FF006A1DB2 /* LineRangeCacheableTests.swift in Sources */,
2A1893AD1FFF6A0100AD244F /* LineSortTests.swift in Sources */,
2AEBD25A246BB4C200EC97A3 /* NSAttributedStringTests.swift in Sources */,
2A89160C2394B87100AC13EE /* NSLayoutManagerTests.swift in Sources */,
2A1380072C225FAF00093BF3 /* NSTextStorageTests.swift in Sources */,
@ -3102,7 +3098,7 @@
2A80BE8E27FFA61700D2F7FF /* LineEndingScanner.swift in Sources */,
2A6416A31D2F9F7200FA9E1A /* LineNumberView.swift in Sources */,
2A1125C323F1A86B006A1DB2 /* LineRangeCacheable.swift in Sources */,
2A1893AA1FFF422D00AD244F /* LineSort.swift in Sources */,
2A1893AA1FFF422D00AD244F /* SortPatternError+Localization.swift in Sources */,
2A59B7042957089A0094F03B /* LinkButton.swift in Sources */,
2AE144C52B0222DB005E8CF1 /* LiveTextInsertionView.swift in Sources */,
2A8961921DB76A3400E9E0EC /* MainMenu.swift in Sources */,

View File

@ -26,6 +26,7 @@
import AppKit
import SwiftUI
import Defaults
import LineSort
extension EditorTextView {

View File

@ -1,282 +0,0 @@
//
// LineSort.swift
//
// CotEditor
// https://coteditor.com
//
// Created by 1024jp on 2018-01-05.
//
// ---------------------------------------------------------------------------
//
// © 2018-2024 1024jp
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
enum SortPatternError: LocalizedError {
case emptyPattern
case invalidRegularExpressionPattern
var errorDescription: String? {
switch self {
case .emptyPattern:
String(localized: "Empty pattern",
table: "PatternSort",
comment: "error message (“pattern” is a regular expression pattern)")
case .invalidRegularExpressionPattern:
String(localized: "Invalid pattern",
table: "PatternSort",
comment: "error message (“pattern” is a regular expression pattern)")
}
}
}
protocol SortPattern: Equatable {
func sortKey(for line: String) -> String?
func range(for line: String) -> Range<String.Index>?
func validate() throws(SortPatternError)
}
extension SortPattern {
/// Sorts given lines with the receiver's pattern.
///
/// - Parameters:
/// - string: The string to sort.
/// - options: Compare options for sort.
/// - Returns: Sorted string.
func sort(_ string: String, options: SortOptions = SortOptions()) -> String {
guard let lineEnding = string.firstLineEnding else { return string }
var lines = string.components(separatedBy: .newlines)
let firstLine = options.keepsFirstLine ? lines.removeFirst() : nil
lines = lines
.map { (line: $0, key: self.sortKey(for: $0)) }
.sorted {
switch ($0.key, $1.key) {
case let (.some(key0), .some(key1)):
// sort items by evaluating values as numbers
// -> This code still ignores numbers in the middle of keys.
if let number0 = options.parse(key0),
let number1 = options.parse(key1),
number0 != number1
{
return number0 < number1
}
return key0.compare(key1, options: options.compareOptions, locale: options.usedLocale) == .orderedAscending
case (.none, .some):
return false
case (.some, .none), (.none, .none):
return true
}
}
.map(\.line)
if options.descending {
lines.reverse()
}
if let firstLine {
lines.insert(firstLine, at: 0)
}
return lines.joined(separator: String(lineEnding))
}
}
// MARK: -
struct EntireLineSortPattern: SortPattern {
func sortKey(for line: String) -> String? {
line
}
func range(for line: String) -> Range<String.Index>? {
line.startIndex..<line.endIndex
}
func validate() throws(SortPatternError) { }
}
struct CSVSortPattern: SortPattern {
var delimiter: String = ","
var column: Int = 1
func sortKey(for line: String) -> String? {
assert(self.column > 0)
let delimiter = self.delimiter.isEmpty ? "," : self.delimiter.unescaped
let index = self.column - 1 // column number is 1-based
let components = line.split(separator: delimiter, omittingEmptySubsequences: false)
return components[safe: index]?.trimmingCharacters(in: .whitespaces)
}
func range(for line: String) -> Range<String.Index>? {
assert(self.column > 0)
let delimiter = self.delimiter.isEmpty ? "," : self.delimiter.unescaped
let index = self.column - 1 // column number is 1-based
let components = line.split(separator: delimiter, omittingEmptySubsequences: false)
guard let component = components[safe: index] else { return nil }
let offset = components[..<index].map { $0 + delimiter }.joined().count
let start = line.index(line.startIndex, offsetBy: offset)
let end = line.index(start, offsetBy: component.count)
// trim whitespaces
let headTrim = component.countPrefix(while: \.isWhitespace)
let endTrim = component.reversed().countPrefix(while: \.isWhitespace)
let trimmedStart = line.index(start, offsetBy: headTrim)
let trimmedEnd = line.index(end, offsetBy: -endTrim)
// oder can be opposite when component contains only whitespace
guard trimmedStart <= trimmedEnd else { return nil }
return trimmedStart..<trimmedEnd
}
func validate() throws(SortPatternError) { }
}
struct RegularExpressionSortPattern: SortPattern {
var searchPattern: String = ""
var ignoresCase: Bool = true
var usesCaptureGroup: Bool = false
var group: Int = 1
var numberOfCaptureGroups: Int { (try? self.regex)?.numberOfCaptureGroups ?? 0 }
func sortKey(for line: String) -> String? {
guard let range = self.range(for: line) else { return nil }
return String(line[range])
}
func range(for line: String) -> Range<String.Index>? {
guard
let regex = try? self.regex,
let match = regex.firstMatch(in: line, range: line.nsRange)
else { return nil }
if self.usesCaptureGroup {
guard match.numberOfRanges > self.group else { return nil }
return Range(match.range(at: self.group), in: line)
} else {
return Range(match.range, in: line)
}
}
/// Tests the regular expression pattern is valid.
func validate() throws(SortPatternError) {
if self.searchPattern.isEmpty {
throw SortPatternError.emptyPattern
}
do {
_ = try self.regex
} catch {
throw SortPatternError.invalidRegularExpressionPattern
}
}
private var regex: NSRegularExpression? {
get throws {
try NSRegularExpression(pattern: self.searchPattern, options: self.ignoresCase ? [.caseInsensitive] : [])
}
}
}
// MARK: -
struct SortOptions: Equatable {
var ignoresCase: Bool = true
var numeric: Bool = true
var isLocalized: Bool = true
var keepsFirstLine: Bool = false
var descending: Bool = false
var locale: Locale = .current // open for unit test
var compareOptions: String.CompareOptions {
.forcedOrdering
.union(self.ignoresCase ? .caseInsensitive : [])
.union(self.numeric ? .numeric : [])
}
var usedLocale: Locale? {
self.isLocalized ? self.locale : nil
}
/// Interprets the given string as numeric value using the receiver's parsing strategy.
///
/// If the receiver's `.numeric` property is `false`, it certainly returns `nil`.
///
/// - Parameter value: The string to parse.
/// - Returns: The numerical value or `nil` if failed.
func parse(_ value: String) -> Double? {
guard self.numeric else { return nil }
let locale = self.usedLocale ?? .init(identifier: "en")
let numberParser = FloatingPointFormatStyle<Double>(locale: locale).parseStrategy
return try? numberParser.parse(value)
}
}

View File

@ -25,6 +25,7 @@
import SwiftUI
import Defaults
import LineSort
struct PatternSortView: View {

View File

@ -0,0 +1,44 @@
//
// SortPatternError+Localization.swift
//
// CotEditor
// https://coteditor.com
//
// Created by 1024jp on 2018-01-05.
//
// ---------------------------------------------------------------------------
//
// © 2018-2024 1024jp
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import LineSort
extension SortPatternError: LocalizedError {
public var errorDescription: String? {
switch self {
case .emptyPattern:
String(localized: "Empty pattern",
table: "PatternSort",
comment: "error message (“pattern” is a regular expression pattern)")
case .invalidRegularExpressionPattern:
String(localized: "Invalid pattern",
table: "PatternSort",
comment: "error message (“pattern” is a regular expression pattern)")
}
}
}

View File

@ -16,6 +16,7 @@ let package = Package(
"FileEncoding",
"FilePermissions",
"FuzzyRange",
"LineSort",
"Shortcut",
"StringBasics",
"Syntax",
@ -31,6 +32,7 @@ let package = Package(
.library(name: "FileEncoding", targets: ["FileEncoding"]),
.library(name: "FilePermissions", targets: ["FilePermissions"]),
.library(name: "FuzzyRange", targets: ["FuzzyRange"]),
.library(name: "LineSort", targets: ["LineSort"]),
.library(name: "StringBasics", targets: ["StringBasics"]),
.library(name: "Syntax", targets: ["Syntax"]),
.library(name: "TextClipping", targets: ["TextClipping"]),
@ -55,6 +57,9 @@ let package = Package(
.target(name: "FuzzyRange"),
.testTarget(name: "FuzzyRangeTests", dependencies: ["FuzzyRange"]),
.target(name: "LineSort", dependencies: ["StringBasics"]),
.testTarget(name: "LineSortTests", dependencies: ["LineSort"]),
.target(name: "StringBasics"),
.testTarget(name: "StringBasicsTests", dependencies: ["StringBasics"]),

View File

@ -0,0 +1,85 @@
//
// CSVSortPattern.swift
// LineSort
//
// CotEditor
// https://coteditor.com
//
// Created by 1024jp on 2024-06-27.
//
// ---------------------------------------------------------------------------
//
// © 2018-2024 1024jp
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
public struct CSVSortPattern: SortPattern, Equatable, Sendable {
public var delimiter: String
public var column: Int
public init(delimiter: String = ",", column: Int = 1) {
self.delimiter = delimiter
self.column = column
}
public func sortKey(for line: String) -> String? {
assert(self.column > 0)
let delimiter = self.delimiter.isEmpty ? "," : self.delimiter.unescaped
let index = self.column - 1 // column number is 1-based
let components = line.split(separator: delimiter, omittingEmptySubsequences: false)
guard components.indices.contains(index) else { return nil }
return components[index].trimmingCharacters(in: .whitespaces)
}
public func range(for line: String) -> Range<String.Index>? {
assert(self.column > 0)
let delimiter = self.delimiter.isEmpty ? "," : self.delimiter.unescaped
let index = self.column - 1 // column number is 1-based
let components = line.split(separator: delimiter, omittingEmptySubsequences: false)
guard components.indices.contains(index) else { return nil }
let component = components[index]
let offset = components[..<index].map { $0 + delimiter }.joined().count
let start = line.index(line.startIndex, offsetBy: offset)
let end = line.index(start, offsetBy: component.count)
// trim whitespaces
let headTrim = component.prefix(while: \.isWhitespace).count
let endTrim = component.reversed().prefix(while: \.isWhitespace).count
let trimmedStart = line.index(start, offsetBy: headTrim)
let trimmedEnd = line.index(end, offsetBy: -endTrim)
// oder can be opposite when component contains only whitespace
guard trimmedStart <= trimmedEnd else { return nil }
return trimmedStart..<trimmedEnd
}
public func validate() throws(SortPatternError) { }
}

View File

@ -0,0 +1,45 @@
//
// EntireLineSortPattern.swift
// LineSort
//
// CotEditor
// https://coteditor.com
//
// Created by 1024jp on 2024-06-27.
//
// ---------------------------------------------------------------------------
//
// © 2018-2024 1024jp
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
public struct EntireLineSortPattern: SortPattern, Equatable, Sendable {
public init() { }
public func sortKey(for line: String) -> String? {
line
}
public func range(for line: String) -> Range<String.Index>? {
line.startIndex..<line.endIndex
}
public func validate() throws(SortPatternError) { }
}

View File

@ -0,0 +1,93 @@
//
// RegularExpressionSortPattern.swift
// LineSort
//
// CotEditor
// https://coteditor.com
//
// Created by 1024jp on 2024-06-27.
//
// ---------------------------------------------------------------------------
//
// © 2018-2024 1024jp
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
public struct RegularExpressionSortPattern: SortPattern, Equatable, Sendable {
public var searchPattern: String
public var ignoresCase: Bool
public var usesCaptureGroup: Bool
public var group: Int
public var numberOfCaptureGroups: Int { (try? self.regex)?.numberOfCaptureGroups ?? 0 }
public init(searchPattern: String = "", ignoresCase: Bool = true, usesCaptureGroup: Bool = false, group: Int = 1) {
self.searchPattern = searchPattern
self.ignoresCase = ignoresCase
self.usesCaptureGroup = usesCaptureGroup
self.group = group
}
public func sortKey(for line: String) -> String? {
guard let range = self.range(for: line) else { return nil }
return String(line[range])
}
public func range(for line: String) -> Range<String.Index>? {
guard
let regex = try? self.regex,
let match = regex.firstMatch(in: line, range: line.nsRange)
else { return nil }
if self.usesCaptureGroup {
guard match.numberOfRanges > self.group else { return nil }
return Range(match.range(at: self.group), in: line)
} else {
return Range(match.range, in: line)
}
}
/// Tests the regular expression pattern is valid.
public func validate() throws(SortPatternError) {
if self.searchPattern.isEmpty {
throw SortPatternError.emptyPattern
}
do {
_ = try self.regex
} catch {
throw SortPatternError.invalidRegularExpressionPattern
}
}
private var regex: NSRegularExpression? {
get throws {
try NSRegularExpression(pattern: self.searchPattern, options: self.ignoresCase ? [.caseInsensitive] : [])
}
}
}

View File

@ -0,0 +1,80 @@
//
// SortOptions.swift
// LineSort
//
// CotEditor
// https://coteditor.com
//
// Created by 1024jp on 2024-06-27.
//
// ---------------------------------------------------------------------------
//
// © 2018-2024 1024jp
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
public struct SortOptions: Equatable, Sendable {
public var ignoresCase: Bool
public var numeric: Bool
public var isLocalized: Bool
public var keepsFirstLine: Bool
public var descending: Bool
var locale: Locale
public init(ignoresCase: Bool = true, numeric: Bool = true, isLocalized: Bool = true, keepsFirstLine: Bool = false, descending: Bool = false, locale: Locale = .current) {
self.ignoresCase = ignoresCase
self.numeric = numeric
self.isLocalized = isLocalized
self.keepsFirstLine = keepsFirstLine
self.descending = descending
self.locale = locale
}
var compareOptions: String.CompareOptions {
.forcedOrdering
.union(self.ignoresCase ? .caseInsensitive : [])
.union(self.numeric ? .numeric : [])
}
var usedLocale: Locale? {
self.isLocalized ? self.locale : nil
}
/// Interprets the given string as numeric value using the receiver's parsing strategy.
///
/// If the receiver's `.numeric` property is `false`, it certainly returns `nil`.
///
/// - Parameter value: The string to parse.
/// - Returns: The numerical value or `nil` if failed.
func parse(_ value: String) -> Double? {
guard self.numeric else { return nil }
let locale = self.usedLocale ?? .init(identifier: "en")
let numberParser = FloatingPointFormatStyle<Double>(locale: locale).parseStrategy
return try? numberParser.parse(value)
}
}

View File

@ -0,0 +1,94 @@
//
// SortPattern.swift
// LineSort
//
// CotEditor
// https://coteditor.com
//
// Created by 1024jp on 2018-01-05.
//
// ---------------------------------------------------------------------------
//
// © 2018-2024 1024jp
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import StringBasics
public enum SortPatternError: Error {
case emptyPattern
case invalidRegularExpressionPattern
}
public protocol SortPattern: Equatable, Sendable {
func sortKey(for line: String) -> String?
func range(for line: String) -> Range<String.Index>?
func validate() throws(SortPatternError)
}
extension SortPattern {
/// Sorts given lines with the receiver's pattern.
///
/// - Parameters:
/// - string: The string to sort.
/// - options: Compare options for sort.
/// - Returns: Sorted string.
public func sort(_ string: String, options: SortOptions = SortOptions()) -> String {
guard let lineEnding = string.firstLineEnding else { return string }
var lines = string.components(separatedBy: .newlines)
let firstLine = options.keepsFirstLine ? lines.removeFirst() : nil
lines = lines
.map { (line: $0, key: self.sortKey(for: $0)) }
.sorted {
switch ($0.key, $1.key) {
case let (.some(key0), .some(key1)):
// sort items by evaluating values as numbers
// -> This code still ignores numbers in the middle of keys.
if let number0 = options.parse(key0),
let number1 = options.parse(key1),
number0 != number1
{
return number0 < number1
}
return key0.compare(key1, options: options.compareOptions, locale: options.usedLocale) == .orderedAscending
case (.none, .some):
return false
case (.some, .none), (.none, .none):
return true
}
}
.map(\.line)
if options.descending {
lines.reverse()
}
if let firstLine {
lines.insert(firstLine, at: 0)
}
return lines.joined(separator: String(lineEnding))
}
}

View File

@ -1,6 +1,6 @@
//
// LineSortTests.swift
// Tests
// LineSortTests
//
// CotEditor
// https://coteditor.com
@ -26,7 +26,7 @@
import Foundation
import Testing
@testable import CotEditor
@testable import LineSort
struct LineSortTests {