mirror of
https://github.com/coteditor/CotEditor.git
synced 2024-09-11 19:27:30 +03:00
Extract LineSort to package
This commit is contained in:
parent
e76d2995ca
commit
f7f15f4c2f
@ -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 */,
|
||||
|
@ -26,6 +26,7 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import Defaults
|
||||
import LineSort
|
||||
|
||||
extension EditorTextView {
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -25,6 +25,7 @@
|
||||
|
||||
import SwiftUI
|
||||
import Defaults
|
||||
import LineSort
|
||||
|
||||
struct PatternSortView: View {
|
||||
|
||||
|
44
CotEditor/Sources/SortPatternError+Localization.swift
Normal file
44
CotEditor/Sources/SortPatternError+Localization.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
@ -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"]),
|
||||
|
||||
|
85
Packages/EditorKit/Sources/LineSort/CSVSortPattern.swift
Normal file
85
Packages/EditorKit/Sources/LineSort/CSVSortPattern.swift
Normal 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) { }
|
||||
}
|
@ -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) { }
|
||||
}
|
@ -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] : [])
|
||||
}
|
||||
}
|
||||
}
|
80
Packages/EditorKit/Sources/LineSort/SortOptions.swift
Normal file
80
Packages/EditorKit/Sources/LineSort/SortOptions.swift
Normal 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)
|
||||
}
|
||||
}
|
94
Packages/EditorKit/Sources/LineSort/SortPattern.swift
Normal file
94
Packages/EditorKit/Sources/LineSort/SortPattern.swift
Normal 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))
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user